# Remote Messages and Events **Page:** kit/exec/remote [Download Raw Markdown](./kit/exec/remote.md) --- # Remote messages and events A running script can publish events to up to 64 listening clients at a time per room, receive messages from them, and answer calls to functions it exposes. Everything happens on the script's own URL: a request header (or one query parameter) turns an ordinary request into a remote operation, so there is no separate API to address. Each capability is off until a magic comment switches it on. ```javascript // @mode worker // @remote-messages // @remote-token dashboard dash-7f3a9c2e5b1d4f60 messages remote.on('message', (message) => { if (message.type !== 'start') return { accepted: false }; runJob(); // not awaited: the reply goes back at once return { accepted: true }; }); async function runJob() { for (let pct = 0; pct <= 100; pct += 25) { remote.emit('progress', { pct }); await new Promise((resolve) => setTimeout(resolve, 500)); } remote.emit('done', { at: new Date().toISOString() }); } return { status: 'ready' }; ``` A client that listens on this script's URL receives every `progress` and `done` event; a client that sends `{ "type": "start" }` starts the job. --- ## Operations | Operation | Request on the script's URL | Needs | |-----------|-----------------------------|-------| | `events` | `GET` with `X-Hoody-Remote: events`, or `GET ?hoody-remote=events`. The answer is a Server-Sent Events stream | `@remote-messages` | | `send` | `POST` with `X-Hoody-Remote: send` and `{ "message": … }` | `@remote-messages` | | `call` | `POST` with `X-Hoody-Remote: call` and `{ "name": "…", "args": [ … ] }` | `@remote-call` | | `capabilities` | `GET` with `X-Hoody-Remote: capabilities` | nothing | | attach | WebSocket offering the subprotocol `hoody-remote.v1`: events, `send` and `call` on one socket | `@remote-messages` | | `eval` | `POST` with `X-Hoody-Remote: eval` | `@remote-eval` and an eval-scoped token | Every answer to a marked request that remote dispatch handles carries the header `X-Hoody-Remote-Version: 1`. The header confirms that the server supports remote operations. It is missing on `OPTIONS` requests (answered with `204` before remote dispatch) and on requests refused before dispatch, such as one with a malformed URL, so only treat its absence on a normal response as "not supported". A request that sends the header twice, names an unknown operation, or puts any value other than `events` in `?hoody-remote=` is refused with `400 REMOTE_UNKNOWN_OP`; the marker is never silently ignored. --- ## Magic comments | Comment | Effect | |---------|--------| | `@remote-messages` | Allows `events`, `send` and attach | | `@remote-call` | Allows `call` | | `@remote-eval` | Allows `eval`. It also needs a `@remote-token` with the `eval` scope | | `@remote-token [scope]` | Declares a named token. Repeatable. `scope` is a comma list of `messages`, `call` and `eval`; without one the token gets `messages,call`. `eval` is never implied | The three capability comments take no value, or exactly `true` or `false`. Any other value is ignored: that line has no effect, and if the comment appears more than once, the last valid line wins. A `@remote-token` name is 1 to 64 letters, digits, dots, underscores or hyphens, and each name may appear once. The secret is either written inline or read from the script's `.env` companions with `env:NAME`. A secret shorter than 16 characters, or an `env:` name with no value, matches nothing. A `@remote-token` line whose values do not parse still counts as declared and matches nothing, so a typo in the values locks the script instead of opening it. The comment itself must be spelled exactly, `// @remote-token` followed by a space (a bare `// @remote-token` with nothing after it counts as a declared token that matches nothing): a line spelled differently is not recognised and protects nothing. Like every magic comment, `@remote-token` lines must sit in the first 30 lines of the file, before any code; one further down is ignored. You can also set these comments through the magic-comments API. Every refusal for a disabled capability carries `details.fix` with both the comment to add and the exact `magic-comments/update` call. --- ## Publish events `remote.emit(event, data)` sends one frame to every client listening to the script at that moment. - `event` is a string of 1 to 128 characters. `attached`, `end`, `console` and `generation` are reserved for the server's own frames and throw a `TypeError`. - `data` must be plain data and at most 8 MiB once serialized. A value that cannot be sent throws a `TypeError` naming the first bad path. See [What can be sent](#what-can-be-sent). - The return value is `{ delivered, dropped }`: how many clients received the frame, and how many could not take it because their queue was full (those clients are disconnected with reason `overflow`). - With no client listening, `emit` returns `{ delivered: 0, dropped: 0 }` and does no serialization work. Any run of the script can emit: an HTTP request, a `@schedule` fire, a `send` or `call` handler, or background work the script started. Events are not stored. A client receives only the events emitted while it is connected, and nothing is replayed when it reconnects. ### Who receives an event Listeners are grouped in **rooms**. A room is one script, with one set of route parameter values, on one deployment (the hostname together with its exec ID). An event emitted while handling `/rooms/42` through `rooms/[id].js` reaches clients listening on `/rooms/42`, not those on `/rooms/43`, and never crosses to another exec ID or subdomain. Remote operations work in both [execution modes](/kit/exec/execution-modes/). Background work that outlives the request, like `runJob()` above, belongs in worker mode, where the script's context stays alive between requests. --- ## Listen for events ```bash # The token is read from HOODY_EXEC_REMOTE_TOKEN (or --remote-token-file) export HOODY_EXEC_REMOTE_TOKEN=dash-7f3a9c2e5b1d4f60 hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/jobs/build events \ --tail info --count 20 --timeout 30s ``` ```typescript const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER }); const conn = containerClient.exec.connect('jobs/build', { token: process.env.DASHBOARD_TOKEN }); for await (const frame of conn.events({ tail: 'info' })) { if (frame.event === 'progress') console.log('progress', frame.data); if (frame.event === 'done') break; // ends the stream } ``` ```bash curl -N "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/jobs/build?tail=info" \ -H "X-Hoody-Remote: events" \ -H "X-Token: dash-7f3a9c2e5b1d4f60" ``` ```javascript // EventSource cannot set headers: the query form carries the marker and the token const url = 'https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/jobs/build' + '?hoody-remote=events&token=dash-7f3a9c2e5b1d4f60'; const source = new EventSource(url); source.onmessage = (e) => { const frame = JSON.parse(e.data); if (frame.event === 'end') source.close(); // stop EventSource from reconnecting else console.log(frame.event, frame.data); }; ``` The stream opens with an `: open` comment, then sends each frame as one `data:` line holding a JSON object, and a `: ping` comment every 15 seconds. Frames carry no SSE `event:` field, so an `EventSource` delivers all of them to `onmessage`; the frame's own `event` member tells them apart. Remote answers carry the script's CORS headers (reflective unless the script sets `@cors`), so a page on another origin can connect. `?tail=` adds the script's console output to the stream. It takes `error`, `warn`, `info` or `debug`, and includes every level at or above the one named (`console.log` counts as `info`). Without it, no console lines are sent. Opening a stream does not run the script. A client that connects before anything emits simply waits. ### Frames | `event` | Shape | When | |---------|-------|------| | `attached` | `{ "event": "attached", "v": 1, "generation": "…", "path": "default/1/jobs/build.js" }` | First frame of every stream and socket; `path` is the script's path from the scripts root | | your event name | `{ "event": "progress", "data": { "pct": 50 }, "generation": "…" }` | Each `remote.emit` | | `console` | `{ "event": "console", "level": "info", "args": [ … ], "kind": "…" }` | Each console call at or above the `tail` level. `console.dir` and `console.table` are not included | | `generation` | `{ "event": "generation", "from": "…", "to": "…" }` | A changed version of the script takes over the handlers (or, with `@remote-eval`, the run scope) of the previous one | | `end` | `{ "event": "end", "reason": "evicted" }` | Last frame, when the server ends the stream | `generation` identifies the version of the script's source that produced a frame. A run that only emits does not send a `generation` frame itself, but its events still carry the new `generation`. A `send` or `call` to a rewritten script sends one even when the script registers no handler, provided the room already holds an earlier version, which any earlier `send` or `call` in that room sets up. With `@remote-eval`, a run that only emits can send one too. ### How a stream ends | `end` reason | Attach close code | Cause | |--------------|-------------------|-------| | `revoked` | `4401` or `4403` | The token that opened the stream no longer admits it, or `@remote-messages` was removed. The frame also carries `status` and `error`. An attach socket gets a `{ "ok": false, … }` refusal frame instead (see [Revoking a token](#revoking-a-token-on-open-streams)) | | `evicted` | `1001` | The script was deleted, disabled with `@enabled false`, switched to the other `@mode`, its URL now leads to another script, or the deployment was dropped (in worker mode, also when a `.env` file the script reads is written, moved or deleted through the management API, or the instance's cache is cleared; in serverless mode, also when its whole shared state is cleared or replaced) | | `overflow` | `1013` | The client's queue (1,000 frames or 8 MiB, frame overhead included) filled, usually because it read too slowly. One event close to 8 MiB can cause it on its own | | `shutdown` | `1001` | The Exec service is stopping | A client that disconnects ends its own stream. The SDK and CLI do not reconnect: the iteration ends with the stream, and reconnecting is up to you. Treat `revoked` as final unless you have a new token. The SDK does not yield a `revoked` frame: the loop throws the refusal instead, as `ExecRemoteTokenError` for a token problem or `ExecRemotePermissionError` for a capability that was switched off. --- ## Send messages and call functions The script registers its handlers with `remote.on('message', fn)` and `remote.expose(name, fn)`. A handler receives the message (or the call's arguments) followed by a context object with `op`, `from` (`http` or `attach`), `token` (the accepted token's name, `@token`, or `null`), `params` (route parameters), `generation`, and `signal`, an `AbortSignal` that fires at the deadline or when the caller disconnects. ```javascript // @mode worker // @remote-messages // @remote-call globalThis.paused ??= false; remote.on('message', async (message, ctx) => { remote.emit('received', { message, from: ctx.from }); return { paused: globalThis.paused }; }); remote.expose('pause', (reason) => { globalThis.paused = true; remote.emit('paused', { reason }); return true; }); return { paused: globalThis.paused }; ``` ```bash hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker send '{"type":"ping"}' hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker call pause '"maintenance"' ``` ```typescript const conn = containerClient.exec.connect('bots/worker', { token: process.env.BOT_TOKEN }); const sent = await conn.send({ type: 'ping' }); console.log(sent.reply); // { paused: false } const called = await conn.call('pause', ['maintenance'], { timeoutMs: 5000 }); console.log(called.result); // true ``` ```bash curl -X POST "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker" \ -H "X-Hoody-Remote: send" -H "Content-Type: application/json" \ -d '{"message": {"type": "ping"}, "timeoutMs": 5000}' # {"ok":true,"reply":{"paused":false},"durationMs":3,"generation":"…"} curl -X POST "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker" \ -H "X-Hoody-Remote: call" -H "Content-Type: application/json" \ -d '{"name": "pause", "args": ["maintenance"]}' # {"ok":true,"result":true,"durationMs":2,"generation":"…"} ``` The request body is a JSON object of at most 1 MiB. `timeoutMs` is optional and is capped by the script's `@timeout`. The reply or result must be plain data (see [What can be sent](#what-can-be-sent)) and at most 8 MiB. ### What can be sent Event data, replies and results are converted to JSON with these rules: - Own enumerable data properties are sent. A `toJSON` method is not called, and getters are never run. - `undefined` object members are left out, and a top-level `undefined` is sent as `null`. `NaN` and infinite numbers become `null`. - A `Date` becomes an ISO string, a `BigInt` a string, a `Map` an array of `[key, value]` pairs and a `Set` an array. An `Error` becomes `{ name, message, stack }`. An `ArrayBuffer` or typed array becomes a base64 string. - Functions, symbols, boxed primitives, getters and setters, proxies and circular references are refused, as is anything nested more than 256 levels deep or holding more than 1,000,000 values. ### When the script runs Every run of the script registers its handlers again, so a rewritten script answers with its new code. When no run of the current version has registered a handler in the room yet, the first `send` or `call` runs the script once, with `metadata.method` set to `REMOTE_INIT`, and then delivers the operation. Operations that arrive together share that one run. - The `REMOTE_INIT` run that `send` or `call` starts, and every handler call, count against `@concurrent`, like any other run of the script. Eval does not: it runs outside `@concurrent`, including the run it starts with `start: true`. - An operation that starts or joins the `REMOTE_INIT` run waits only until the handler it needs is registered (the message handler for `send`, the named function for `call`), not for the rest of the run. Later operations that find their handler already registered skip the wait, but still need a free `@concurrent` slot. Register handlers once everything they use is ready. - If the `REMOTE_INIT` run throws, it takes back the handlers it registered (not ones another run has since replaced them with): an operation still waiting for its handler gets `500 REMOTE_INIT_FAILED`, and the next operation runs the script again if no handler is left. An operation that had already picked its handler, including one waiting for a `@concurrent` slot, still runs it. - A late run of an older version of the script never replaces the current version's handlers. An operation sent to the older version while a newer one came in gets `409 REMOTE_SCRIPT_CHANGED`: its handler did not run, and sending it again reaches the new version. An operation that had already picked its handler before the newer version came in, for example one waiting for a `@concurrent` slot, still runs the older handler. - An operation that reaches its deadline gets `504 REMOTE_TIMEOUT`. A handler that is already running is not stopped: `ctx.signal` aborts, and the handler runs to its end. One still waiting for a `@concurrent` slot is dropped and never runs. A handler that throws answers `422 REMOTE_THREW`, with the error in `details.error`. --- ## Attach over one WebSocket An attach socket carries events, `send` and `call` on one connection. Open a WebSocket on the script's URL offering the subprotocol `hoody-remote.v1`; `?tail=` works as for `events`. The first frame is `attached`, followed by the same frames an events stream gets, except that a revoked token ends the socket with a `{ "ok": false, … }` refusal frame rather than an `end` frame. To send, write a JSON text frame `{ "id": 1, "op": "send", "message": … }` or `{ "id": 2, "op": "call", "name": "pause", "args": [ … ] }`. The answer echoes the `id`: `{ "id": 1, "ok": true, "reply": … }`, `{ "id": 2, "ok": true, "result": … }`, or `{ "id": 1, "ok": false, "status": 403, "error": { … } }`. `eval` is refused on a socket. A socket can have 256 operations running at once (`429 REMOTE_TOO_MANY_INFLIGHT` beyond that) and closes with code `1009` on a frame larger than 1 MiB. Every `send` or `call` frame that passes these limits checks the script again, against its file as it is at that moment, before it runs; a frame refused earlier (not JSON, an unknown `op`) does not. In the SDK, `ws: true` moves `send`, `call` and `events` onto one attach socket, and `conn.attach()` opens an explicit session with `send`, `call`, `frames()` and `close()`. The CLI's `attach` operation prints frames to stdout and sends each stdin line. Attaching needs `@remote-messages`, and a `@remote-token` used for it must have the `messages` scope: a `call`-only token works over HTTP but cannot attach, so give it `messages,call` to call over the socket. ```typescript const conn = containerClient.exec.connect('bots/worker', { token: process.env.BOT_TOKEN, ws: true }); ``` Browsers cannot set headers on a WebSocket. The token travels as a second subprotocol entry, `hoody-token.`, which the server reads and never echoes. The SDK does this for you. --- ## Evaluate code in a running script `eval` runs an expression inside the scope of the script's latest run of its current version (the most recently started run, whether it is still in flight or already finished; after an edit, run the script again or pass `start: true`), so it can read and change the script's top-level variables. The latest-run target returns `409 REMOTE_SCOPE_UNAVAILABLE` if the script binds or assigns to `eval`, uses `with`, or replaces the global `eval`; in worker mode, `target: "globals"` still works then, but it reaches only the globals, not the run's local variables. It needs `@remote-eval` and a `@remote-token` whose scope includes `eval`; `@token` never unlocks it. It is always a one-shot `POST`, never an attach frame, and it is not held back by `@concurrent`. ```bash hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker eval 'Object.keys(shared)' ``` ```typescript const out = await conn.eval('Object.keys(shared)'); console.log(out.result, out.logs); ``` ```bash curl -X POST "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker" \ -H "X-Hoody-Remote: eval" -H "X-Token: $OPS_EVAL_TOKEN" -H "Content-Type: application/json" \ -d '{"expression": "Object.keys(shared)"}' ``` The body takes `expression`, or `function` (a function's source, called as `fn(ctx, ...args)`) with `args`, plus optional `target`, `start` and `timeoutMs`. `target: "globals"` evaluates in the worker context's globals instead and needs `@mode worker`. With the default target, `start: true` runs the script once first when no run scope is available; without it, such a request gets `409 REMOTE_NO_RUN`. `target: "globals"` needs no earlier run: it ignores `start` and answers with `"run": null`. Each room keeps only its latest finished run scope, and finished scopes are kept for at most 32 rooms per deployment, so an older one can be gone even though its script already ran. The answer is `{ "ok": true, "result": …, "logs": [ … ], "run": { … }, "durationMs": …, "generation": "…" }`, where `logs` holds the console lines the evaluated code wrote. `console.dir` and `console.table` output is not included. Capture stops at 1,000 lines or 1 MiB of text; when it stops early, the answer also carries `"logsTruncated": true`. --- ## Tokens Remote operations (events, `send`, `call`, attach, eval, and the `REMOTE_INIT` run) do not run the directory's `pre.ts` or `post.ts`. A login check you wrote in `pre.ts` does not protect the remote channel: protect it with the tokens below, and check anything else inside your handlers. The token check runs before the capability check, in this order: 1. If the script declares any `@remote-token`, only those tokens admit remote operations. The script's `@token` no longer does. 2. Otherwise, the script's [`@token`](/kit/exec/authentication/) admits `events`, `send`, `call` and attach. 3. Otherwise, remote operations need no token. Anyone who can reach the container URL can use the enabled capabilities, subject to your [proxy permissions](/foundation/proxy/permissions/). `@token` and a missing token never unlock `eval`: it always needs a `@remote-token` with the `eval` scope. Send the token as `X-Token`, `Authorization: Bearer`, the password of `Authorization: Basic`, `?token=`, or a `hoody-token.*` subprotocol entry on attach. The SDK sends it only as `X-Token` or the subprotocol entry, never in a URL. | Code | Status | Meaning | |------|--------|---------| | `REMOTE_TOKEN_REQUIRED` | 401 | The script needs a token and none was sent | | `REMOTE_TOKEN_INVALID` | 401 | The token matches none of the script's tokens | | `REMOTE_TOKEN_SCOPE` | 403 | The token is valid, but its scope does not cover this operation (other than `eval`) | | `REMOTE_EVAL_TOKEN_REQUIRED` | 403 | `eval` needs a `@remote-token` with the `eval` scope; other tokens, `@token` and open access do not admit it | | `REMOTE_MESSAGES_DISABLED` | 403 | The script has no `@remote-messages` | | `REMOTE_CALL_DISABLED` | 403 | The script has no `@remote-call` | ### Revoking a token on open streams An open stream or socket is checked again on every 15-second ping, against the script as it is on disk at that moment, and when a changed version of the script starts a run, before that run's frames are sent. Removing or rotating a token, removing `messages` from its scope, or removing `@remote-messages` therefore ends the streams that token opened within about 15 seconds, unless another current token still accepts the credentials they connected with. Removing only `call` or `eval` from its scope leaves them open. The client receives the refusal it would get if it connected now (unless the change was a `.env` write through the management API in worker mode, which ends the stream as `evicted` instead): a final `{ "event": "end", "reason": "revoked", "status": 401, "error": { … } }` frame on an events stream, or a `{ "ok": false, "status": 401, "error": { … } }` frame (on both, status `403` instead of `401` when the token lacks the scope or `@remote-messages` is gone) followed by close code `4401` or `4403` on an attach socket. A run of an older version of the script, for example one that waited behind `@concurrent`, never ends a stream that the current file still admits. The re-check applies the same token order as a new connection. Removing the last `@remote-token` line hands remote operations back to `@token`, or to no token at all when the script has none, so a stream the fallback admits stays open. To cut every client off, remove `@remote-messages` or replace the token with a new one. --- ## Check what a script allows `capabilities` reports what the script enables, the names and scopes of its tokens (never the secrets), the handlers and exposed functions registered by its current version, its latest run (recorded only for scripts with `@remote-eval`, otherwise `null`), and the number of open streams. For a script with no remote comment at all, the last two are left out. Disabled capabilities carry their `fix`. ```bash hoody exec remote https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker capabilities ``` ```typescript const caps = await conn.capabilities(); console.log(caps.capabilities?.call?.exposed); // ['pause'] ``` ```bash curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/bots/worker" \ -H "X-Hoody-Remote: capabilities" -H "X-Token: $BOT_TOKEN" ``` If the script has `@token` or `@remote-token` lines, `capabilities` without a valid token answers with the token names only. A script with neither answers in full to anyone. --- ## Limits | Limit | Value | |-------|-------| | Open streams and sockets per room | 64 (`429 REMOTE_TOO_MANY_SESSIONS` for the next one) | | Rooms kept per deployment | 256 in all. Creating a room past that drops the least recently used idle rooms; rooms in use are kept, so the total can go over 256. A dropped room loses its registered handlers, and its next `send` or `call` runs `REMOTE_INIT` again | | Queued frames per client | 1,000 frames or 8 MiB, then `overflow` | | Event name, function name | 128 characters | | Exposed functions per room | 1,024 names; exposing another new name throws a `RangeError` (replacing an existing one is allowed) | | Event data, reply, call result | 8 MiB serialized | | `send` / `call` request body, attach frame | 1 MiB | | Operations running on one attach socket | 256 | | Keep-alive and token re-check | every 15 seconds | --- ## Best Practices - **Scope tokens to what the client does.** A dashboard that only listens needs a `messages` token; give `call` and `eval` to separate tokens. - **Keep secrets in `.env` companions.** `env:NAME` keeps the secret out of the script file and its history. - **Prefer headers to `?token=`.** The Exec service removes a `?token=` value before it logs the request, but a URL can still end up in browser history. Use the SDK, the CLI or a header wherever the client allows it. - **Emit small frames.** Each frame is serialized once and queued per client; large payloads fill slow clients' queues first. - **Reconnect in the client.** Events emitted while a client is disconnected are lost, so fetch current state through `send` or `call` after reconnecting. --- ## Troubleshooting | Symptom | Cause and fix | |---------|---------------| | `403 REMOTE_MESSAGES_DISABLED` | Add `// @remote-messages`. The error's `details.fix` holds the comment and the API call | | `401 REMOTE_TOKEN_INVALID` with a correct `@token` | The script declares a `@remote-token`, which replaces `@token` for remote operations | | `409 REMOTE_NO_MESSAGE_HANDLER` | The script never calls `remote.on('message', …)` | | `404 REMOTE_FUNCTION_NOT_FOUND` | No `remote.expose` with that name; `details.exposed` lists the names that exist | | `409 REMOTE_SCRIPT_CHANGED` | The script was replaced while the operation waited, so its handler did not run. Send it again and it reaches the new version | | The stream opens but no events arrive | Nothing emitted since the client connected, or the emitting run handled another route parameter value (another room) | | A browser `EventSource` keeps reconnecting after `end` | Call `source.close()` when a frame has `event: "end"` | | `405 REMOTE_METHOD` | `events` and `capabilities` are `GET`; `send`, `call` and `eval` are `POST` | --- ## What's Next