Differences
This shows you the differences between two versions of the page.
| Both sides previous revision Previous revision Next revision | Previous revision | ||
| iqxwebnext:technical:web_jobs [2026/10/03 17:51] – Gareth Johnstone | iqxwebnext:technical:web_jobs [2026/10/05 20:02] (current) – Gareth Johnstone | ||
|---|---|---|---|
| Line 2: | Line 2: | ||
| # Web Jobs (JavaScript) | # Web Jobs (JavaScript) | ||
| - | A **Web Job** is a `.js` file that WebHub runs on request and answers over HTTP — a stored procedure written in JavaScript, or a small web page. Jobs can query the IQX database, call the public internet, use a short list of approved npm modules, and return JSON, a file (CSV, PDF…) or an HTML page. They are called from browsers, from iqxWEB, from third-party webhooks, or on a cron schedule. Each run executes in its own worker thread with a runtime, memory and CPU limit, so a badly written job fails on its own without taking the node down. | + | A **Web Job** is a short JavaScript program |
| - | This page is about the JavaScript jobs served from `/ | + | Jobs are written and managed from the **Web Jobs** pages in iqxWEB (Owner → Web Jobs → *Manage* and *Scheduler*) and served from `/ |
| + | |||
| + | This page is about the JavaScript jobs served from `/ | ||
| ## Writing a job | ## Writing a job | ||
| - | A job file is the **body of an async function**. Use `await` and `return` directly; there is no wrapper to remember. | + | A job is the **body of an async function**. Use `await` and `return` directly; there is no wrapper to remember. |
| ```javascript | ```javascript | ||
| Line 32: | Line 34: | ||
| The rules that matter: | The rules that matter: | ||
| - | - **The return value is the result.** A plain value or object is sent as JSON; a `response.*` value is sent as that response. `finish ( result )` still works (jobs written before October 2026 use it) and whichever of the two happens first wins. | + | - **The return value is the result.** A plain value or object is sent as JSON; a `response.*` value is sent as that response. |
| - **An uncaught error fails the run immediately** with `JOB-001` and the message — including a rejected promise nobody caught and a throw inside a timer. You no longer need a `try/catch` just to make failures prompt; catch only where you want to turn an error into a particular answer. | - **An uncaught error fails the run immediately** with `JOB-001` and the message — including a rejected promise nobody caught and a throw inside a timer. You no longer need a `try/catch` just to make failures prompt; catch only where you want to turn an error into a particular answer. | ||
| - | - **`success: false` in a JSON result marks the run as failed.** The caller gets a `JOB-001` error line *instead of* your payload, the run is counted as a failure, and a scheduled run is recorded as failed. Use it to mean "this did not work". For "no rows matched" | + | - **`success: false` in a JSON result marks the run as failed.** The caller gets a `JOB-001` error *instead of* your payload, the run is counted as a failure, and a scheduled run is recorded as failed. Use it to mean "this did not work". For "no rows matched" |
| - | - **The header comment is the job's contract.** `Key: value` lines become metadata (`Name`, `Description`, | + | - **The header comment is the job's contract.** `Key: value` lines become metadata (`Name`, `Description`, |
| + | |||
| + | ### The header, in full | ||
| + | |||
| + | ``` | ||
| + | /* | ||
| + | Name: Staff PDF report | ||
| + | Description: | ||
| + | Author: IQX Limited | ||
| + | Version: 1.2.0 | ||
| + | Last Updated: 2026-10-05 | ||
| + | Runtime: 60000 ms of wall clock (default 10000) | ||
| + | Memory: 512 MB of heap (default 256) | ||
| + | CPU: 20000 ms of CPU time (default 5000) | ||
| + | Access: owner | ||
| + | Mode: page when the job renders a browser page | ||
| + | */ | ||
| + | ``` | ||
| ## What a job can see | ## What a job can see | ||
| Line 43: | Line 62: | ||
| | Global | What it is | | | Global | What it is | | ||
| |---|---| | |---|---| | ||
| - | | `params` | The request parameters. `GET` → the query string (**every value is a string**, so `Number ( params.limit )` yourself); `POST` → the parsed JSON or form body. A checkbox group arrives as a string for one tick and an array for several. On a scheduled run it is the schedule' | + | | `params` | The request parameters. `GET` → the query string (**every value is a string**, so `Number ( params.limit )` yourself); `POST` → the parsed JSON or form body. A checkbox group arrives as a string for one tick and an array for several. On a scheduled run it is the schedule' |
| - | | `userID`, `isAuthenticated` | The caller' | + | | `request` | `{ method, url, path, query, headers, ip, caller |
| - | | `request` | `{ method, url, path, query, headers, ip }` — tells a page job a landing `GET` from a submitted `POST`, and lets it read a cookie. The `Authorization` header is withheld. | | + | | `userID`, `isAuthenticated` | The caller' |
| - | | `db.exec ( sql, params? )` | Runs the SQL as the pears (business-table) user with `?` placeholders bound from the array, and resolves to an **array of row objects** for a `SELECT`. The connection carries the caller' | + | | `db.exec ( sql, params? )` | Runs the SQL as the pears (business-table) user with `?` placeholders bound from the array, and resolves to an **array of row objects** for a `SELECT` |
| | `http ( url, options? )` | Outbound HTTP. `options`: `method`, `headers`, `body` (a string, bytes, or an object which is sent as JSON), `username`/ | | `http ( url, options? )` | Outbound HTTP. `options`: `method`, `headers`, `body` (a string, bytes, or an object which is sent as JSON), `username`/ | ||
| | `require ( name )` | Loads an **approved** module synchronously: | | `require ( name )` | Loads an **approved** module synchronously: | ||
| - | | `console` | `log`/ | + | | `console` | `log`/ |
| - | | `response` | Builders for non-JSON answers — see *Responses*. `rawResponse ( body, headers )` is the older form of `response.raw`. | | + | | `response` | Builders for non-JSON answers — see *Responses*. | |
| | `sign ( payload, ttlSeconds? )`, `verify ( token )` | A signed, expiring token (HMAC, keyed from the server secret). `verify` returns the payload or `null`. This is how a multi-step page proves that an earlier step passed — see *Serving pages*. Default TTL 15 minutes. | | | `sign ( payload, ttlSeconds? )`, `verify ( token )` | A signed, expiring token (HMAC, keyed from the server secret). `verify` returns the payload or `null`. This is how a multi-step page proves that an earlier step passed — see *Serving pages*. Default TTL 15 minutes. | | ||
| + | | `breakpoint ( )` | Pauses the job when it is being run under the debugger; a no-op otherwise (so is a plain `debugger` statement). See *Debugging*. | | ||
| | `limits` | The resolved `{ runtimeMs, memoryMb, cpuMs }` for this run. | | | `limits` | The resolved `{ runtimeMs, memoryMb, cpuMs }` for this run. | | ||
| | `Buffer`, `URL`, `URLSearchParams`, | | `Buffer`, `URL`, `URLSearchParams`, | ||
| - | | `extendLimits ( )` | Deprecated — declare limits in the header instead. Still extends the runtime for old jobs and logs a warning. | | ||
| - | ## Limits | + | ## Who may call a job |
| - | Jobs are meant to be snappy. The defaults are 10 seconds of wall clock, 256 MB of heap and 5 seconds of CPU time. A job that needs more declares it in its header, which is deliberate: it is an acknowledgement that this job is slow, and it is visible in the jobs list. | + | WebHub works out **who is calling** before anything runs: a signed-in user (from the bearer token or the iqxWEB session cookie), the hub's system token, the scheduler, or nobody. It then checks |
| - | ``` | + | | `Access:` | Who may run it | |
| - | /* | + | |---|---| |
| - | Name: Staff PDF report | + | | `public` | Anyone, with no credentials. Rate limited per caller (`WEBJOB_PUBLIC_RATE`, |
| - | Runtime: 60000 | + | | `user` | Any signed-in user. **The default** for every other job. | |
| - | Memory: 512 | + | | `owner` | OWNER users only. | |
| - | CPU: 20000 | + | | `key` | Reserved for an API key pinned to this job (keys are planned, not yet issued). Owners can run it; everyone else is refused. | |
| - | */ | + | |
| - | ``` | + | The system token and the scheduler always pass. A refused call gets `AUTH-SESSION-001` (401, nobody signed in) or `SYS-403` (signed in, but not enough). Inside the job, `request.caller.kind` says which of these happened, so one job can serve a visitor and a consultant differently, |
| + | |||
| + | For an external system that must call a non-public job today, the honest options are the system token, or a `public` job that checks a signed token or shared secret in its parameters with `verify ( )`. | ||
| + | |||
| + | ## Limits | ||
| + | |||
| + | Jobs are meant to be snappy. The defaults are 10 seconds | ||
| - | Each value is capped by a server maximum (`WEBJOB_MAX_RUNTIME_MS` 300 000, `WEBJOB_MAX_MEMORY_MB` 2048, `WEBJOB_MAX_CPU_MS` 120 000); a higher declaration is clamped and logged. The runtime limit terminates the worker. The memory limit is enforced by V8 on the worker' | + | Each value is capped by a server maximum (`WEBJOB_MAX_RUNTIME_MS` 300 000, `WEBJOB_MAX_MEMORY_MB` 2048, `WEBJOB_MAX_CPU_MS` 120 000); a higher declaration is clamped and logged. The runtime limit terminates the worker. The memory limit is enforced by V8 on the worker' |
| - | Two more guards are per node rather than per job. At most `WEBJOB_MAX_CONCURRENT` (8) workers run at once; further requests queue for up to 15 seconds and are then refused with `SYS-429`. And `public_` jobs are rate limited | + | Two more guards are per node rather than per job. At most `WEBJOB_MAX_CONCURRENT` (8) workers run at once; further requests queue for up to 15 seconds and are then refused with `SYS-429`. And callers without credentials |
| One deployment caveat: a `--max-old-space-size` in the WebHub process' | One deployment caveat: a `--max-old-space-size` in the WebHub process' | ||
| Line 78: | Line 103: | ||
| 1. A request arrives at `GET` or `POST / | 1. A request arrives at `GET` or `POST / | ||
| - | 2. A name starting `public_` runs without authentication | + | 2. WebHub resolves the caller and checks the job's access level (previous section). |
| - | 3. WebHub | + | 3. It reads the job's source from the job store (no such job → `SYS-404`), parses the header, resolves the limits, and starts a **worker thread** with an empty environment, |
| 4. The job runs; the host kills it on the runtime or CPU limit, V8 on the memory limit. | 4. The job runs; the host kills it on the runtime or CPU limit, V8 on the memory limit. | ||
| - | 5. The result is recorded in history, | + | 5. The run — parameters, |
| - | The worker is a containment boundary for mistakes, not a wall against a hostile author — someone who can write a job already has full SQL against the database, and `vm` contexts are escapable by design. That is why writing and uploading jobs is limited to **OWNER** users, and why a `public_` job should treat every parameter as untrusted input. | + | The worker is a containment boundary for mistakes, not a wall against a hostile author — someone who can write a job already has full SQL against the database, and `vm` contexts are escapable by design. That is why writing and uploading jobs is limited to **OWNER** users, and why a `public` job should treat every parameter as untrusted input. |
| ## Responses | ## Responses | ||
| Line 102: | Line 127: | ||
| How that reaches the caller depends on who is asking: | How that reaches the caller depends on who is asking: | ||
| - | - **A browser** (`Accept: text/html`, or any caller adding `?raw=1`) gets an ordinary HTTP response: the status, the headers, the body. HTML pages get a job-page `Content-Security-Policy` that allows inline scripts and `https:` assets unless the job sets its own. | + | - **A browser** (a navigation, or `Accept: text/html`) gets an ordinary HTTP response: |
| - | - **An API client and the iqxWEB | + | - **An API client and the iqxWEB |
| ``` | ``` | ||
| Line 110: | Line 135: | ||
| ``` | ``` | ||
| - | For a raw response the metadata line is followed by the bytes. With `enableLogger=1` each `console.*` call arrives first as a `{" | + | For a raw response the metadata line is followed by the bytes. With `enableLogger=1` each `console.*` call arrives first as a `{" |
| ```bash | ```bash | ||
| Line 121: | Line 146: | ||
| ## Serving pages | ## Serving pages | ||
| - | A job can be a small web application: | + | A job can be a small web application: |
| ```javascript | ```javascript | ||
| Line 142: | Line 167: | ||
| To chain into a *different* job, redirect to it with the token in the URL (`response.redirect`) and `verify` on arrival; a browser can only be redirected with a `GET`, so the token is the only trustworthy thing it can carry. | To chain into a *different* job, redirect to it with the token in the URL (`response.redirect`) and `verify` on arrival; a browser can only be redirected with a `GET`, so the token is the only trustworthy thing it can carry. | ||
| - | Templates live in the database, not in files: a `WebJobTemplate ( Name, Html )` table read with `db.exec` and a few lines of `{{placeholder}}` replacement (the deep-link job shows one with escaped and raw placeholders). What you owe a public page, because the engine cannot do it for you: HTML-escape every value that came from the database or the request; check posted IDs against what you offered; keep tokens short-lived; | + | Templates live in the database, not in files: a `WebJobTemplate ( Name, Html )` table read with `db.exec` and a few lines of `{{placeholder}}` replacement (the deep-link job shows one with escaped and raw placeholders). What you owe a public page, because the engine cannot do it for you: HTML-escape every value that came from the database or the request; check posted IDs against what you offered; keep tokens short-lived; |
| - | ## Managing jobs | + | Declare a page job with `Mode: page` in its header. The engine treats it like any other job, but the Owner page does the right thing with it: *Open* opens the page in a browser tab instead of the run dialog, the list shows a *page* badge, a *Pages* filter finds them, and *Debug* attaches to the real page (see *Debugging*). A job page is a real page with its own forms and scripts, so it always opens in its own tab — it is not embedded inside iqxWEB. |
| - | All under `/ | + | To see what a page job logged while using it, add `_console=1` to its URL: the HTML comes back with a collapsible drawer underneath holding the job's console lines with timings, the run time, the method and parameters it saw, and the request `ref`. The drawer |
| - | | Method and path | What it does | | + | ## The Owner pages |
| - | |---|---| | + | |
| - | | `GET /available` | Every `.js` in the jobs folder with metadata, resolved limits, whether it is public, run statistics and modified time. | | + | |
| - | | `GET /< | + | |
| - | | `POST /save` `{ name, code }` | Write (or overwrite) `jobs/< | + | |
| - | | `POST /upload` (multipart `file`, `name`) | Same, from an uploaded `.js`. | | + | |
| - | | `DELETE /< | + | |
| - | | `POST /execute` (multipart `file`, `params`, `enableLogger`) | Run an uploaded file once without saving it — the editor' | + | |
| - | | `GET /running`, `POST /< | + | |
| - | | `GET / | + | |
| - | | `GET /metrics` | Totals, success rate, runtimes, failures today, and `workersRunning` / `workersQueued` / `workersMax`. | | + | |
| - | | `POST /cron` | Re-read the schedule table. | | + | |
| - | History | + | **Manage** lists every job on one line — name, badges (`public`, `page`, `extended`, |
| + | |||
| + | **History** (per job, or per schedule from the Scheduler page) lists the recent runs with status, duration, who or what started them and the node that ran them; click one to replay its console output with timings | ||
| + | |||
| + | **Scheduler** lists schedules the same way: a switch | ||
| + | |||
| + | ## Debugging | ||
| + | |||
| + | A job can be stepped through line by line from the Owner page: *Debug* in a job's Run menu, or the *Debug* button in the editor (which runs the unsaved code). The dialog shows the source with the paused line highlighted, | ||
| + | |||
| + | Put a breakpoint where you want to stop: | ||
| + | |||
| + | ```javascript | ||
| + | const rows = await db.exec(" | ||
| + | breakpoint() | ||
| + | const names = rows.map(r => r.Name) | ||
| + | ``` | ||
| + | |||
| + | Without any breakpoint the run pauses before the first line. Stepping pauses before each statement in the job body and inside `async` functions (including `if`/ | ||
| + | |||
| + | For a **page job**, *Debug* attaches to the real flow: the dialog opens *live*, waiting; **Open page** opens the job in a new tab with the debugger attached, and every request that tab makes — the landing `GET`, | ||
| + | |||
| + | `breakpoint ( )` and `debugger` are **no-ops in a normal run** — a normal run is never instrumented and `breakpoint` is an empty function in the sandbox — so one left in a job does nothing in production; only an OWNER starting a debug session makes them pause. | ||
| ## Scheduled jobs | ## Scheduled jobs | ||
| - | Schedules are rows read through | + | Schedules are rows of the `IQXNetWebJobSchedule` table: an ID, a cron expression, |
| + | |||
| + | Each firing runs the job as the system user with the stored parameters (invalid JSON is logged and the job runs with `{}`), sees `request.method === " | ||
| + | |||
| + | On a multi-node site every node loads every schedule today, so a schedule fires once per node. | ||
| + | |||
| + | ## Run history | ||
| + | |||
| + | Every run — manual, scheduled, from the editor or under the debugger — is recorded with its parameters, its result (text results up to 64 KB), what it wrote to the console (up to 300 lines / 32 KB, with timings), how it ended, who or what started it, and the node it ran on. The History dialog replays this, and the per-job stats on the Manage page and the metrics cards are computed from it. | ||
| + | |||
| + | History lives in **MongoDB** by default (`WEBJOB_HISTORY_STORE=mongo`): | ||
| + | |||
| + | ## Where the jobs live | ||
| + | |||
| + | `WEBJOB_STORE=file` (the default) keeps jobs as `jobs/< | ||
| + | |||
| + | `WEBJOB_STORE=db` keeps them in the `IQXNetWebJob` table instead (`Name`, `Code`, `UpdatedAt`, | ||
| + | |||
| + | ## Managing jobs over HTTP | ||
| + | |||
| + | All under `/ | ||
| + | |||
| + | | Method and path | What it does | | ||
| + | |---|---| | ||
| + | | `GET /available` | Every job with metadata, resolved limits, `access`, `mode`, run statistics, where it is stored and when it changed. | | ||
| + | | `GET /< | ||
| + | | `POST /save` `{ name, code }` | Write (or overwrite) a job. | | ||
| + | | `POST /upload` (multipart `file`, `name`) | Same, from an uploaded `.job.js`. | | ||
| + | | `DELETE /< | ||
| + | | `POST /execute` (multipart `file`, `params`, `enableLogger`) | Run an uploaded file once without saving it — the editor' | ||
| + | | `GET /running`, `POST /< | ||
| + | | `GET / | ||
| + | | `GET /metrics` | Totals, success rate, runtimes, failures today, `workersRunning` / `workersQueued` / `workersMax`, | ||
| + | | `POST / | ||
| + | | `POST / | ||
| + | | `POST / | ||
| + | | `GET /store`, `POST / | ||
| + | | `GET / | ||
| + | | `POST /cron` | Re-read the schedule table; reports how many schedules loaded. | | ||
| ## Error codes | ## Error codes | ||
| Line 171: | Line 245: | ||
| | Code | Meaning | | | Code | Meaning | | ||
| |---|---| | |---|---| | ||
| - | | `JOB-001` | The job failed: it threw, returned `success: false`, a query inside it failed, | + | | `JOB-001` | The job failed: it threw, returned `success: false`, a query inside it failed, it has a syntax error, or it was stopped from the debugger. `detail` has the message. | |
| | `JOB-002` | `require ( )` of a module that is not approved or not installed. | | | `JOB-002` | `require ( )` of a module that is not approved or not installed. | | ||
| | `JOB-003` | A limit was hit: runtime, memory or CPU. The message says which. | | | `JOB-003` | A limit was hit: runtime, memory or CPU. The message says which. | | ||
| - | | `JOB-004` | The job finished without a result (`finish ( )` with nothing, or nothing returned and `finish` never called). | | + | | `JOB-004` | The job finished without a result (nothing was returned). | |
| - | | `SYS-429` | Too many: the public | + | | `AUTH-SESSION-001`, `SYS-403` | The caller may not run this job (nobody signed in / not enough) — see *Who may call a job*. | |
| + | | `SYS-429` | Too many: the rate limit for callers without credentials, or the node's worker queue is full. | | ||
| Full table on **Web error codes**; every failure carries a `ref` to search the server logs with. | Full table on **Web error codes**; every failure carries a `ref` to search the server logs with. | ||
| Line 181: | Line 256: | ||
| ## Trying it | ## Trying it | ||
| - | The repo ships a test pack. Run each and compare with its header's " | + | The repo ships a small test pack; run each and compare with its header. `_test-limits` returns the resolved limits, and `? |
| - | + | ||
| - | ## Jobs written before October 2026 | + | |
| - | + | ||
| - | The earlier engine ran the file as a synchronous function body, so every job wrapped itself in `(async function (params, userID, finish) { … })(params, userID, finish)` and used `await require ( )`, `getDB ( )` and `finish ( )`. All of that still works unchanged inside the new wrapper; the only behavioural difference is that an uncaught error now fails at once instead of waiting for the timeout, and `extendLimits ( )` logs a deprecation pointing at the header. | + | |
| ## For developers | ## For developers | ||
| - | - `src/ | + | - `src/ |
| - | - `src/ | + | - `src/ |
| - `src/ | - `src/ | ||
| - | - `src/ | + | - `src/ |
| - | - `src/ | + | - `src/ |
| + | - `src/ | ||
| + | - `src/ | ||
| + | - `src/ | ||
| + | - `src/ | ||
| - Settings are `WEBJOB_*` in `.env.sample`. A new failure kind needs a `JOB-00n` entry in `@iqx-limited/ | - Settings are `WEBJOB_*` in `.env.sample`. A new failure kind needs a `JOB-00n` entry in `@iqx-limited/ | ||
| - | - Not done yet, by design: | + | - Not done yet, by design: |
| </ | </ | ||