Support returning objects, functions and streams from custom modules
Les mainteneurs répondent en général sous 1 jour
Personne n'a encore pris cette issue.
Évaluation
- Difficulté
- 5/5
- Temps estimé
- Plus d'une semaine
- Accessibilité débutants
- 25/100
- Type d'issue
- Fonctionnalité
- Clarté
- Plutôt claire
- Activité
- Active
- Stack technique
- typescript
- Domaine
- backend-api-design, distributed-systems
Piste de recherche
Start by tracing host module calls, assertRuntimeValue, and workspace_runtime_journal; the issue does not name source files or tests. Read how WorkspaceModuleCallContext, replay, and buildModuleGraph work, then map the proposed no-replay RPC path and replayable handle path onto those entry points. Done means returned objects can be called through the bridge with per-call checks and context, and replay can revive JSON-backed handles consistently.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Description
Summary
Host modules are Workers RPC under the hood: program code calls the host through the backend's
bridge, an RpcTarget. But the bridge only lets plain data through. assertRuntimeValue refuses
anything that isn't JSON ("Host module values must be JSON-compatible."), so a host module can
only export flat functions that take and return JSON.
As a result, every module that gives code objects is really two: a host module of flat
functions, and a source module, run in the isolate, that wraps them in objects. 0.9.0 lets the
source half have a codemode:* name, and the README shows the pattern:
modules: {
"codemode:weather": { forecast: ([city]) => lookUpForecast(String(city)) },
"codemode:forecasts": `import { forecast } from "codemode:weather"; export const weather = { forecast };`,
}
We'd like host modules to return what Workers RPC can carry, such as RpcTarget objects whose
methods become stubs, functions and ReadableStreams, so a module is one module. The rest of
this issue covers where that runs into computer's own guarantees (replay, per-call context,
limits, access) and proposes how to keep them.
What the split looks like today
Take a jobs API that code should use through handles:
import { createJob } from "codemode:jobs";
const job = await createJob({ name: "build" });
const run = await job.start({ ref: "main" });
const { status } = await run.wait({ signal: AbortSignal.timeout(60_000) });
Because a host module can't return job or run, it has to be two modules:
modules: {
// Flat functions over IDs, returning outcomes instead of throwing.
"codemode:jobs-host": {
createJob: async ([options]) => ({ ok: true, value: { id: await jobs.create(options) } }),
startJob: async ([{ id, input }]) => ({ ok: true, value: { runId: await jobs.start(id, input) } }),
waitRun: async ([{ runId, timeoutMs }], ctx) => /* ... */,
},
// Source that rebuilds the objects, the errors and the timeout in the isolate.
"codemode:jobs": `
import * as host from "codemode:jobs-host";
const unwrap = (o) => { if (o.ok) return o.value; throw Object.assign(new Error(o.message), { code: o.code }); };
class Run { constructor(id) { this.id = id; } wait(options) { /* turn options.signal into timeoutMs */ } }
class Job { constructor(id) { this.id = id; } async start(input) { return new Run(unwrap(await host.startJob({ id: this.id, input })).runId); } }
export async function createJob(options) { return new Job(unwrap(await host.createJob(options)).id); }
`,
}
Other common cases have the same shape:
- A client per service:
import { github } from "codemode:apis"; await github.repos.get({...}),
a sourceProxyover a hostcall(service, method, args). - A process with streams, like
ctx.container.exec()'s: source that rebuilds
ReadableStreams by polling host primitives (spawn,read,write,wait,kill), under
a second name beside the host module. - A database connection:
const db = await connect(); await db.query(sql), a source class
over a hostquery(connectionId, sql).
What we'd like to write instead:
class Run extends RpcTarget {
constructor(readonly id: string) { super(); }
async wait([options], ctx) { return jobs.wait(this.id, { signal: ctx.signal }); }
}
class Job extends RpcTarget {
constructor(readonly id: string) { super(); }
async start([input], ctx) { return new Run(await jobs.start(this.id, input, { key: ctx.callId })); }
}
modules: {
"codemode:jobs": {
createJob: async ([options], ctx) => new Job(await jobs.create(options, { key: ctx.callId })),
},
}
The split has costs:
- The hidden half can be imported. No host module can be kept from program code, so a program
can importcodemode:jobs-hostand callwaitRun({...})directly, without the handles, thrown
errors or timeouts. The host checks its arguments, so this isn't a security problem, but it
makes internals into API that can't change freely. - The description lists it. A host that wants to hide the internal module from a model has
to edit the backend's description itself. - Two names per module, and the same code written twice: once as host functions, and once
as source that puts the objects back together.
Where it runs into computer's guarantees
1. Replay (the main one)
With onInterrupt: "replay", every host call's response is saved as JSON in
workspace_runtime_journal. When the Durable Object restarts, the run starts again and each call
is answered from the journal, in order. ctx.interrupt() builds on that: a run waits, its object
can be evicted or redeployed, and it resumes days later.
A stub can't be saved in the journal, and it's dead after a restart:
const job = await createJob({ name: "build" }); // journaled as... a stub?
const run = await job.start({ ref: "main" });
await approval(); // ctx.interrupt(): the run is released
// deploy; the Durable Object restarts; the run replays from the top
await run.wait(); // run must work again, without starting a second run
Getting run back by calling start again during replay breaks the rule that replayed calls are
answered and not run, and anything marked unsafeToRepeat. A feature that only works without
replay would leave out every run that waits with ctx.interrupt().
2. Per-call context
Only a module's exported functions get WorkspaceModuleCallContext: callId (an idempotency key
that stays the same on replay), signal and deadline, access, and interrupt(). Workers RPC
would call a returned stub's methods directly, so they'd get no context, and none of the bridge's
checks:
- the read-only check (
access: "read"), maxCapabilityCallsandmaxCapabilityBytes,- the call's deadline and cancellation when the execution ends,
- a
callIdto make the call idempotent.
3. Callbacks into the isolate
Workers RPC lets code pass functions and stubs into the host too:
await subscribe("deploys", (event) => console.log(event)); // a stub of isolate code, held by the host
The host would then be calling untrusted code, which can hang, call back in, or be gone once the
execution ends. These can't be journaled either.
4. Lifetime
A stub keeps its target alive until it's disposed or its connection breaks. A target can also be
a stub itself, from another Worker or Durable Object the host module calls, and a restart of
either breaks it mid-run.
5. Streams
ReadableStream crosses RPC with backpressure, but what it carries can't be journaled cheaply.
That's why a replay-safe process module has to poll output in bounded, journaled read() calls
instead.
6. Errors
Custom error classes, and own properties such as code, may not survive RPC as they are,
depending on compatibility flags. A replayed error must also look like the live one. Today the
source half turns outcomes into thrown errors itself.
7. Descriptions
The description lists a module's exports by name. Objects with methods would want their shapes
described, or a description per class.
Proposal
A. Without replay: whatever Workers RPC carries, through the bridge
For runs with onInterrupt: "fail" or "restart", let host module results include RpcTargets,
functions and ReadableStreams. Computer wraps each returned target, so every method call on it
goes through the bridge like an exported function's call: it gets a context, counts toward the
limits, honours access, and is cut off when the execution ends, when computer disposes of the
run's targets (see Decisions). Callbacks the isolate passes in are only valid for the call they're
passed to.
B. With replay: handles that computer can journal and revive
A host module may return a handle: an RpcTarget that can describe itself as JSON, together
with a way to get it back from that JSON:
import { handle } from "@cloudflare/computer";
class Run extends RpcTarget {
constructor(readonly id: string) { super(); }
[handle.serialize]() { return { id: this.id }; }
async wait([options], ctx) { /* ctx as for any host call */ }
}
const jobsModule = {
createJob: async ([options], ctx) => new Job(await jobs.create(options, { key: ctx.callId })),
[handle.revive]: {
Job: ({ id }) => new Job(id),
Run: ({ id }) => new Run(id),
},
};
- The journal records a handle as
{ $handle: "codemode:jobs#Run", value: { id } }. - Each method call on it is its own journaled step, keyed by the call that made the handle and the
call's order, so it replays like any other call. It gets a context of its own, with a stable
callId. - On replay, computer gives the isolate a stub that answers from the journal. Once replay
catches up, it revives the target from its JSON, so later calls are live again, after a deploy
too. - Functions, callbacks and streams in a replayable run are refused by default, or allowed only
withunsafeToRepeat(), so a replay fails the run instead of running them twice.
With B, each of the examples above is one host module, with no internal half to hide and no
source to keep in step with it. A process module returns a process whose streams are marked
unsafeToRepeat, as its spawn would be anyway.
Smaller step meanwhile
Until then, a private flag on a host module factory would help: it means "only the backend's
source modules may import this, and don't describe it". buildModuleGraph resolves the program's
imports, which would refuse it, while prepareSourceModules resolves the source modules' imports,
which would allow it. It would stop programs importing internal modules and keep them out of the
description, though modules would still be two names.
Decisions
- Context: each method call gets its own. A handle's method gets the context of its own call
(itscallId,signal,deadlineandinterrupt()), not that of the call that made the
handle: the signal and deadline belong to the call. - Handle kinds are named per module. A module declares its kinds (
"Job","Run") in its
[handle.revive]map, and the journal records them under the module's specifier
(codemode:jobs#Run). There's no global registry of classes, so two modules can both have a
Run. - Disposal reaches the host.
[Symbol.dispose](orusing) on a stub in the isolate
disposes of its target in the host. When an execution ends, computer disposes of every target
the run still holds, live or revived, so a host object never outlives its run. - Errors cross in one shape. Computer carries a host error as
{ name, message, code, ...props }(own enumerable properties that are JSON) and throws it in
the isolate as anErrorwith those properties, in live calls and replayed ones alike, so code
sees the same error either way. Modules no longer need to return outcomes for code to turn into
errors.
- Langage dominant
- TypeScript
- Étoiles
- 9.8k
- Forks
- 581
- Merge moyen
- 1 j 14 h
- PR mergées (30 j)
- 64
Préparer son environnement
- Aucun Dockerfile ni fichier Docker Compose
- Propose un modèle de pull request
- Lire le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Autres issues de cloudflare/computer
-
Export one file helper over workspace.fsPeut-être pris @aron-cf l’a pris il y a 16 jours. Ouverteenhancement
cloudflare/computer#261 · 1 commentaire · 1 réaction · 1 personne assignée ·
Les mainteneurs répondent en général sous 1 jour
-
dofs: resolving a directory scans its children as the directory growsPeut-être pris @aron-cf l’a pris il y a 23 jours. Ouvertebug
cloudflare/computer#236 · 1 commentaire · 1 personne assignée ·
Les mainteneurs répondent en général sous 1 jour
-
enhancement
Difficulté 4/5 3-5 jours Accessibilité débutants 30/100
cloudflare/computer#224 · 5 commentaires · 1 réaction ·
Les mainteneurs répondent en général sous 1 jour
-
Always run the "next" computerd workflow on push to the `release` branchPeut-être pris @aron-cf l’a pris il y a 28 jours. Ouvertebug
cloudflare/computer#222 · 2 commentaires · 1 réaction · 1 personne assignée ·
Les mainteneurs répondent en général sous 1 jour
-
Workspace namespaces: more than one Workspace per Durable Object storagePeut-être à nouveau libre @aron-cf l’a pris il y a 28 jours, et aucune pull request n’est ouverte. Ouverteenhancement
cloudflare/computer#219 · 1 commentaire · 1 réaction · 1 personne assignée ·
Les mainteneurs répondent en général sous 1 jour
Toutes les issues de cloudflare/computer
Issues similaires
-
Add: Upin & Ipin Channel HDOuvertecheck:passed streams:add
Difficulté 2/5 1-3 heures Accessibilité débutants 72/100
Les mainteneurs répondent en général sous 1 jour
-
S: triage
Difficulté 2/5 1-3 heures Accessibilité débutants 75/100
Les mainteneurs répondent en général sous 1 jour
-
iot client
Difficulté 2/5 1-3 heures Accessibilité débutants 82/100
Open-Locker/Open-Locker#365 ·
Les mainteneurs répondent en général sous 1 jour
-
bug enhancement
Difficulté 2/5 1-3 heures Accessibilité débutants 69/100
inmanta/web-console#7787 ·
Les mainteneurs répondent en général sous 1 jour
-
bug
Difficulté 2/5 1-3 heures Accessibilité débutants 65/100
shoelace-style/webawesome#2979 ·
Les mainteneurs répondent en général sous 1 jour