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:
The token comes from an environment variable, so the file can go into source control. Set the variable in your shell:
# bash / zsh
# 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.
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:
Trial edition, installed under the full name with an npm alias, so the path doesn't change when you move to the full edition:
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:
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#
E401orUnable to authenticate: the token or password is wrong, or the variable isn't set in the shell that runsnpm. 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-trialon theplen-trialregistry. Also check that the URL ends in/npm/on both lines.- Requests go to
npm.dhtmlx.comorregistry.npmjs.org: another@dhx:registryline wins. Runnpm config get @dhx:registryin the project folder. A project.npmrcoverrides your user config. - The lockfile still points at the old registry after a change: the lockfile stores full tarball URLs. Delete
node_modulesand 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 = ;
In the browser, import the ES module and await its default export, init, once before the first call:
await ;
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 = ;
const = ;
const = ;
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:
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:
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 = ;
const = ;
const = new;
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.solvewith data loads and schedules the snapshot. Its dates become the project's baseline.previewruns 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.applycommits the change and marks the project as unsolved. It returns anApplyReport.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. Thefinallyblock 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:
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 is now true. Your copy of the plan and the project's copy no longer match, so the next solve(undefined, ...) fails:
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#
- JavaScript usage: the
Projectglue, and how it differs from the raw surface. - Wire format: the envelope, field spelling, ids, dates and numbers.
- Module and handles: build targets, instantiation, freeing handles and threads.
- Project data: tasks, links, calendars, resources and assignments.
- Scheduling a project: one-shot and stateful scheduling, options and subtrees.
- Reading results: updates, conflicts, cycles and notes.
- Working-time calendars: hours, weekdays and date exceptions.
- Editing a project:
apply, baselines and resync. - Previewing changes: what-if results without committing.
- Leveling resources: remove resource overallocation.
- Error handling: error envelopes, host-level throws and what's data instead of an error.
- API reference: every export and payload.