Quick start#

By the end of this page, you'll have a Node.js script that schedules two linked tasks through the raw WebAssembly exports, and a live project that reschedules them when one changes.

If you build a browser or Node.js app and want a Project class with typed errors, start at JavaScript usage instead. This page uses the raw surface: JSON strings in, JSON strings out.

Requirements#

  • Node.js 18 or later for the examples on this page, or a modern browser with WebAssembly support.
  • No runtime dependencies.

Install#

The package comes from a private npm registry at git.dhtmlx.com. With your license or trial you received a username and a password for one of two editions:

Edition Registry URL Package
Full https://git.dhtmlx.com/api/packages/plen/npm/ @dhx/plen-wasm
Trial https://git.dhtmlx.com/api/packages/plen-trial/npm/ @dhx/plen-wasm-trial

The trial is the full engine with one limit: a project can have at most 25 tasks. The exports are the same.

Each account sees only its own edition. Trial credentials can't install @dhx/plen-wasm, and full credentials only see the plen registry.

Configure access to the registry#

The examples use the full registry. For the trial, replace plen with plen-trial in the URL, on both lines.

Option A: access token (recommended). Sign in at https://git.dhtmlx.com with your username and password, open Settings โ†’ Applications, and create an access token with the scope package: Read. Then create an .npmrc next to your package.json:

@dhx:registry=https://git.dhtmlx.com/api/packages/plen/npm/
//git.dhtmlx.com/api/packages/plen/npm/:_authToken=${DHTMLX_NPM_TOKEN}

The token comes from an environment variable, so the file can go into source control. Set the variable in your shell:

# bash / zsh
export DHTMLX_NPM_TOKEN=your-token
# PowerShell
$env:DHTMLX_NPM_TOKEN = "your-token"

npm fails when the variable is missing, so set it in every environment that installs: as a secret on your CI server, and in Docker builds. npm, pnpm, Bun and Yarn 1 read .npmrc.

Option B: username and password. Pass the base64-encoded username:password pair instead of a token. This doesn't work for accounts with two-factor authentication.

@dhx:registry=https://git.dhtmlx.com/api/packages/plen/npm/
//git.dhtmlx.com/api/packages/plen/npm/:_auth=${DHTMLX_NPM_AUTH}
export DHTMLX_NPM_AUTH=$(printf '%s' 'your-username:your-password' | base64)

Yarn 2 and later read .yarnrc.yml instead of .npmrc:

npmScopes:
  dhx:
    npmRegistryServer: "https://git.dhtmlx.com/api/packages/plen/npm/"
    npmAuthToken: "${DHTMLX_NPM_TOKEN}"

Already using npm.dhtmlx.com. The @dhx scope can point to one registry only. If your project already installs DHTMLX packages such as @dhx/gantt from https://npm.dhtmlx.com/, keep that mapping. The planning engine packages are published there too, and your existing login installs them. If it can't see them, contact DHTMLX support.

Install the package#

Full edition:

npm install @dhx/plen-wasm

Trial edition, installed under the full name with an npm alias, so the path doesn't change when you move to the full edition:

npm install @dhx/plen-wasm@npm:@dhx/plen-wasm-trial

With pnpm or Yarn, use pnpm add or yarn add with the same argument.

To move from the trial to the full edition later, change plen-trial to plen on both lines of .npmrc, use a token or the username and password of your full-edition account, and run npm install @dhx/plen-wasm@^1.

Find the raw files#

The package holds two builds of the module, each with the .wasm binary and the generated JS bindings:

  • pkg-node/: the Node.js CommonJS module.
  • pkg/: the browser ES module.

The package's exports map points only to the JS glue, so require("@dhx/plen-wasm/pkg-node/plen_wasm.js") fails with ERR_PACKAGE_PATH_NOT_EXPORTED. For raw use, load the generated files by path. The examples below assume you copied pkg-node/ (or pkg/) next to your script:

cp -r node_modules/@dhx/plen-wasm/pkg-node .

A relative path into node_modules works too, such as require("./node_modules/@dhx/plen-wasm/pkg-node/plen_wasm.js").

If the install fails#

  • E401 or Unable to authenticate: the token or password is wrong, or the variable isn't set in the shell that runs npm. Check that the token has the scope package: Read.
  • E404 Not Found: the registry and the package don't match. Trial credentials see only @dhx/plen-wasm-trial on the plen-trial registry. Also check that the URL ends in /npm/ on both lines.
  • Requests go to npm.dhtmlx.com or registry.npmjs.org: another @dhx:registry line wins. Run npm config get @dhx:registry in the project folder. A project .npmrc overrides your user config.
  • The lockfile still points at the old registry after a change: the lockfile stores full tarball URLs. Delete node_modules and the package's lockfile entry, then install again.

Load the module#

The two targets load differently.

In Node.js, require loads and instantiates the module in one step. It's ready right away:

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

In the browser, import the ES module and await its default export, init, once before the first call:

import init, { JsonProject, schedule } from "./pkg/plen_wasm.js";

await init();

init() fetches plen_wasm_bg.wasm from the same folder as the JS file, so serve both files over HTTP. If you call an export before init() resolves, the call fails.

That's the only async step. Every export after it is synchronous. Module and handles covers initSync and other ways to instantiate.

Schedule a snapshot#

Start with the smallest useful plan: two tasks and a finish-to-start link. Both tasks start on Monday, 2026-08-10, at 08:00, so task 2 starts too early. It must wait for task 1 to finish.

Save this as schedule.cjs:

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

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const response = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!response.ok) {
  const { kind, message, path } = response.error;
  console.error(`${kind}: ${message} (at ${path})`);
  process.exit(1);
}
for (const update of response.result.updates) {
  console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
}

Run it with node schedule.cjs:

task 2: 2026-08-12 08:00 -> 2026-08-13 08:00

schedule takes a ProjectData snapshot and SolveOptions as JSON text ("{}" means all defaults). It returns JSON text too, an envelope that looks like this:

{
  "ok": true,
  "result": {
    "baselineId": "1-35c089b962e633b0",
    "conflicts": [],
    "converged": true,
    "cycles": [],
    "notes": [],
    "updates": [
      { "end_date": "2026-08-13 08:00", "id": 2, "kind": "task", "start_date": "2026-08-12 08:00" }
    ]
  }
}

result is a ScheduleResult. Its updates list holds only the tasks that moved. Task 1 didn't move, so it's not in the list. The baselineId is an opaque token, so don't compare it across runs.

Durations are working time in days. The default calendar works Monday to Friday, 08:00-12:00 and 13:00-17:00. That's why the two-day task 1 ends on Wednesday at 08:00, not on Tuesday evening.

Dates are strings: local time, minute precision, no time zone. Output dates are always YYYY-MM-DD HH:mm. Ids are numbers or strings, and 1 and "1" name the same task. Field names follow the wire format: task data is snake_case (start_date), while options and most result fields are camelCase (baselineId).

When the input is invalid#

Every leaf task needs two of start_date, end_date and duration. Remove task 2's start_date and keep only its duration. schedule doesn't throw. It returns an error envelope:

{
  "error": {
    "code": null,
    "kind": "validation",
    "message": "task needs two of start_date / end_date / duration",
    "path": "tasks[1].start_date"
  },
  "ok": false
}

The script prints it and exits with code 1:

validation: task needs two of start_date / end_date / duration (at tasks[1].start_date)

path points to the field that failed. Add the start date back and the script runs again.

Bad input is the only thing that fails this call. Dependency cycles, constraint conflicts and dropped links come back as data in an ok: true result (cycles, conflicts, notes).

Keep a live project#

schedule fits batch jobs and request handlers that see each snapshot once. An editor works differently. It loads the plan once, then sends small changes as the user works. That's what a JsonProject handle does.

This script loads the same two tasks, previews a change, then commits it. Save it as live.cjs:

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

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const project = new JsonProject("{}");
try {
  const first = unwrap(project.solve(JSON.stringify(data), "{}"));
  console.log(`loaded, ${first.updates.length} task(s) moved`);

  // The user stretches task 1 to three days.
  const changes = JSON.stringify({ tasks: { update: [{ id: 1, duration: 3 }] } });

  const preview = unwrap(project.preview(changes, JSON.stringify({ baselineId: first.baselineId })));
  console.log(`preview: ${preview.updates.length} task(s) would move`);

  const report = unwrap(project.apply(changes));
  if (report.needsResync) {
    throw new Error("an edit was rejected; reload the project with solve(dataJson, ...)");
  }

  const result = unwrap(project.solve(undefined, "{}"));
  for (const update of result.updates) {
    console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
  }
} finally {
  project.free();
}

Run it with node live.cjs:

loaded, 1 task(s) moved
preview: 2 task(s) would move
task 1: 2026-08-10 08:00 -> 2026-08-13 08:00
task 2: 2026-08-13 08:00 -> 2026-08-14 08:00

Each call does one job:

  • new JsonProject("{}") creates an empty project with default options.
  • solve with data loads and schedules the snapshot. Its dates become the project's baseline.
  • preview runs the change on a copy of the baseline. The project doesn't change, so it's safe to call on every mouse move during a drag.
  • apply commits the change and marks the project as unsolved. It returns an ApplyReport.
  • solve(undefined, ...) reschedules the current state. Task 1 is reported because it was edited, and task 2 moves to follow it.
  • free() releases the project's WebAssembly memory. The finally block makes sure it runs even when a call fails.

Edits are SchedulingChanges. A TaskUpdate changes only the fields you send. A field you leave out stays as it is, and null clears it.

The handle lives in WebAssembly memory, not in the JS heap. Call free() on every project you create, and on every clone(). A finalizer frees leaked handles eventually, but only as a fallback. After free(), any call on the handle throws.

When the options are invalid#

The constructor is the one export that throws on bad input. It throws the error envelope as a string, not an Error. Pass an unknown validation mode and you get this:

try {
  new JsonProject('{"validation":"loose"}');
} catch (thrown) {
  console.log(JSON.parse(thrown).error.message); // unknown validation mode
}

Every other method returns its errors in the envelope, as unwrap expects.

When an edit is rejected#

apply accepts what it can and reports the rest. A bad operation doesn't produce an error envelope. Remove a task that doesn't exist, and you still get ok: true, with the problem in rejected:

{
  "needsResync": true,
  "rejected": [{ "message": "unknown task id", "path": "tasks.remove[0]" }]
}

needsResync is now true. Your copy of the plan and the project's copy no longer match, so the next solve(undefined, ...) fails:

{
  "error": { "code": "needs-resync", "kind": "project", "message": "needs-resync", "path": null },
  "ok": false
}

To fix it, send the full data again with solve(JSON.stringify(data), ...). That's why the example checks needsResync before it solves. Recover from rejected changes covers this flow in detail.

Next steps#