Forward JSON from a server#

Schedule a client's request body on the server and send the result back without parsing the JSON on either side.

The raw exports take JSON text and return JSON text. If the request body is already ProjectData, it can go straight into schedule, and the returned envelope can go straight into the response. The engine does all the validation.

Prerequisites#

  • The Node build of the module (pkg-node/). See Quick start.
  • Clients that send ProjectData as the request body and read the envelope from the response.

Steps#

  1. Load the module and fix the solve options on the server. The client sends only the data:

    const { schedule } = require("./pkg-node/plen_wasm.js");
    
    const OPTIONS = "{}";
    
  2. Read the request body as a string. Don't call JSON.parse on it:

    function readBody(req) {
      return new Promise((resolve, reject) => {
        let body = "";
        req.setEncoding("utf8");
        req.on("data", (chunk) => (body += chunk));
        req.on("end", () => resolve(body));
        req.on("error", reject);
      });
    }
    
  3. Pass the body to schedule and write the returned string to the response:

    const envelope = schedule(body, OPTIONS);
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(envelope);
    

    Bad input doesn't throw. Malformed JSON, an empty body or an invalid field comes back as an ok: false envelope, just like a successful result.

  4. Catch host-level throws around the call. Only an internal engine failure (a WebAssembly.RuntimeError) gets past schedule this way. Answer it with a 500:

    let envelope;
    try {
      envelope = schedule(body, OPTIONS);
    } catch (e) {
      console.error(e);
      res.writeHead(500).end();
      return;
    }
    

The complete server:

const http = require("node:http");
const { schedule } = require("./pkg-node/plen_wasm.js");

const OPTIONS = "{}";

function readBody(req) {
  return new Promise((resolve, reject) => {
    let body = "";
    req.setEncoding("utf8");
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => resolve(body));
    req.on("error", reject);
  });
}

http
  .createServer(async (req, res) => {
    if (req.method !== "POST" || req.url !== "/schedule") {
      res.writeHead(404).end();
      return;
    }

    const body = await readBody(req);

    let envelope;
    try {
      envelope = schedule(body, OPTIONS);
    } catch (e) {
      // A wasm trap, not bad input. Bad input comes back in the envelope.
      console.error(e);
      res.writeHead(500).end();
      return;
    }

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(envelope);
  })
  .listen(8080);

Choose the HTTP status#

The server above always answers 200 and leaves the ok check to the client. To send a 4xx for an error envelope instead, parse the envelope on the server. That's the only reason to parse it:

const status = JSON.parse(envelope).ok ? 200 : 422;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(envelope);

Don't test the text with something like envelope.startsWith('{"ok":true'). The key order in the envelope isn't part of the wire format.

Keep a project per document#

schedule builds a project, solves it and drops it on every request. For an editor that sends small edits, keep one JsonProject per open document and forward each body to the matching call:

Request body Call
ProjectData, on load or resync solve(body, options)
SchedulingChanges apply(body), then solve(undefined, options)
SchedulingChanges, as a preview preview(body, options)
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const projects = new Map(); // document id → JsonProject

function load(docId, body) {
  let project = projects.get(docId);
  if (!project) {
    // Options are a server constant, so the constructor won't throw for bad input.
    project = new JsonProject("{}");
    projects.set(docId, project);
  }
  return project.solve(body, JSON.stringify({ includeUnchanged: true }));
}

function applyChanges(docId, body) {
  // Returns the ApplyReport envelope. The client checks `rejected` and `needsResync`,
  // then asks for the new dates.
  return projects.get(docId).apply(body);
}

function solveAgain(docId) {
  return projects.get(docId).solve(undefined, "{}");
}

function close(docId) {
  projects.get(docId)?.free();
  projects.delete(docId);
}

A body that fails solve with a parse or validation error leaves the project as it was. The next solve(undefined, …) returns the previous result. Free each project when its document closes. See Freeing handles.

Keep the event loop free#

Every export is synchronous. While schedule or solve runs on a large project, the server handles no other request. Run the module in worker threads and pass the body and envelope strings to and from them. See Run in a worker.

Result#

Start the server with node server.js, then post a project:

curl -s -X POST localhost:8080/schedule \
  -H "Content-Type: application/json" \
  -d '{"config":{"use_calendars":false},"tasks":[{"id":"design","start_date":"2026-01-01","duration":2},{"id":"build","start_date":"2026-01-01","duration":1}],"links":[{"id":"design-build","source":"design","target":"build"}]}'

The response body is the envelope, exactly as schedule returned it:

{"ok":true,"result":{"baselineId":"1-0708b4453c2beb09","conflicts":[],"converged":true,"cycles":[],"notes":[],"updates":[{"end_date":"2026-01-04 00:00","id":"build","kind":"task","start_date":"2026-01-03 00:00"}]}}

Post a date that doesn't exist, such as "start_date":"2026-13-01", and the response is still a 200 with an error envelope:

{"error":{"code":null,"kind":"validation","message":"invalid date","path":null},"ok":false}

See Error handling for every error kind the client can get back.