[Canonical page](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/forward-json-from-a-server/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# 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`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/project-data.md>), it can go straight into [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>), 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](<https://docs.dhtmlx.com/planning-engine/wasm/quick-start.md#build>).
- Clients that send `ProjectData` as the request body and read the [envelope](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md#envelope>) from the response.

## Steps

1. Load the module and fix the [solve options](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/solve-options.md>) on the server. The client sends only the data:

   ```js
   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:

   ```js
   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`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) and write the returned string to the response:

   ```js
   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](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md#envelope>), just like a successful result.

4. Catch [host-level throws](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#host-level-throws>) around the call. Only an internal engine failure (a `WebAssembly.RuntimeError`) gets past `schedule` this way. Answer it with a 500:

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

The complete server:

```js
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:

```js
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](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md#envelope>).

## Keep a project per document

[`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) builds a project, solves it and drops it on every request. For an editor that sends small edits, keep one [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) per open document and forward each body to the matching call:

| Request body | Call |
| --- | --- |
| [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/project-data.md>), on load or resync | [`solve(body, options)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) |
| [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) | [`apply(body)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>), then `solve(undefined, options)` |
| `SchedulingChanges`, as a preview | [`preview(body, options)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>) |

```js
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](<https://docs.dhtmlx.com/planning-engine/wasm/module-and-handles.md#freeing-handles>).

## Keep the event loop free

Every export is synchronous. While [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) or [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) 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](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/run-in-a-worker.md>).

## Result

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

```sh
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`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) returned it:

```json
{"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:

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

See [Error handling](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md>) for every error kind the client can get back.
