second-worker #
The case after tools/hello-worker, per docs/worker-runtime-portability.md §8: not a bigger
worker, but the same shape extended with a manifest that denies a network domain, a schedule that
fires twice, and a channel that delivers one command. Between them, this worker and its host turn
four of the five operations that document's §4.6 named unimplemented in hello-worker —
installGlobals, ensureSchedule, cancelSchedule, openChannel — into code a real run
exercises, and the fifth, RunContext, into the second parameter worker.js actually reads.
See docs/worker-runtime-portability.md §4 for the interface this follows and §4.6 for the table
this worker fills in the rest of.
Files #
manifest.json— grantsdatastoreandnetwork: { domains: ["allowed.example"] }. The narrow domain list is the point:worker.jsdeliberately fetches a different host to prove the guard denies it.worker.js— the entry functionsecond(api, ctx). Branches onctx.trigger.kind: a"schedule"trigger reads the store and probes the denied domain; a"command"trigger logs the command and publishes an acknowledgement throughctx.waitUntil. ExportscapturedApi, read at the module's own import time (mirroringapps/desktop/renderer/cmd/nouns.js's first line) so the host can check whether the module graph was actually fresh this run.run.mjs— host and adapter fused into one file, the same shapetools/hello-worker/run.mjsis. One named function perdocs/worker-runtime-portability.md§4.4 operation (installGlobals,loadWorkerModule,ensureSchedule/cancelSchedule,openChannel,log), adeclaresobject per §4.3, andrunWorker()as the §4.4WorkerHost.run(). Drives three runs: two off an in-processsetIntervalstanding in for a schedule, one off a simulated inbound channel message.
Running it #
node tools/second-worker/run.mjs
No flags, no credential, no network: the store is always the in-memory one
(tools/feeds-worker-spike/memory-store.mjs), and the one real network attempt the worker makes is
to a domain the manifest does not grant, which the guard denies before any DNS lookup happens.
Sample output #
[info] [second-worker] worker.js captured this run's own api object at import time — fresh module graph confirmed (§4.5)
[info] [second-worker] run start: trigger="schedule", deadline in 4998ms
[info] [second-worker] store backend "memory", scheduled for 2026-09-22T08:53:42.126Z
[info] [second-worker] network guard denied as declared: network capability does not cover denied.example (manifest allows: allowed.example)
...
[info] [second-worker] run start: trigger="command", deadline in 4999ms
[info] [second-worker] command "ping" received, params={"at":1790067222630}
[channel] publish "second-worker:ping-result" {"name":"ping","ok":true}
[info] [second-worker] result published on "second-worker:ping-result"
--- results
run 1: ok in 3ms
run 2: ok in 3ms
run 3: ok in 2ms
--- network
manifest allows: allowed.example
DENY denied.example https://denied.example/config
DENY denied.example https://denied.example/config
second-worker ok.
The "fresh module graph confirmed" line is not decorative: temporarily importing worker.js by a
fixed specifier instead of the cache-busted one in loadWorkerModule() flips it to WARNING: ... module graph was reused (§4.5 violated) on runs 2 and 3, while everything else keeps
"succeeding" — which is exactly the silent failure mode docs/workers-design.md §13.4 measured in
nouns.js and the reason §4.5 is not an optional recommendation. That regression check was run by
hand while writing this worker and is not wired into any test; see "What this does not do."
What this settles #
The per-run context object earns its place as a second parameter, not a namespace on api.
docs/worker-runtime-portability.md open question 4 asked this; writing worker.js settled it
empirically, for a reason that surfaced only once the worker had to actually read ctx.trigger,
ctx.deadline and ctx.waitUntil: api's contract is "presence of a namespace means the manifest
granted it" (§2, §7 — absent is absent, never stubbed). ctx's fields are not gated by the
manifest at all; every worker gets a trigger, a deadline and a waitUntil, regardless of what it
declared. Hanging them off api as api.run.* would put two different kinds of fact behind the
same object — "what you may do" and "why you are running right now" — and the second run this
worker makes shows why that matters concretely: onCommand() reads ctx.trigger.name and
ctx.trigger.resultTopic, values that exist only because this run is a command delivery, not
because the manifest grants anything about commands. A worker author checking typeof api.commands
to ask "can I register a command handler" and ctx.trigger.kind to ask "is this run a command"
would be asking the same-shaped question of two objects that mean different things by "present."
Keeping them separate keeps that distinction legible. docs/worker-runtime-portability.md §10 open
question 4 is updated with this reasoning.
What this proves, and what it does not #
Proves: installGlobals populating window.app before import, a schedule producing two distinct
RunContexts with two distinct scheduledFor values, a channel delivering one command trigger
with ctx.waitUntil used for the resulting publish, the network guard denying a real fetch
attempt, and the module graph being fresh on every run — the sharpest thing hello-worker left
unproven (its entry function never touches window.app).
Does not prove: anything about a target other than a single Node process (§5.1's "resident
supervisor" is simulated by one setInterval, not built); anything about ctx.deadline actually
biting (5 seconds, never approached); anything about ctx.signal reaching a network call (this
guard, from tools/feeds-worker-spike/worker-api.mjs, does not accept one — wiring the abort
signal into installNetworkGuard() is unbuilt); an uncaught network denial failing a run, which
is conformance assertion 5's job with a probe worker built for exactly that, not this one's
(worker.js catches the denial deliberately, the way a real feature would); and durability of any
kind — every schedule and channel here disappears with the process, which is the honest answer for
what a bare Node target can offer without an OS-level scheduler or a resident process (§5.1).