Support returning objects, functions and streams from custom modules
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 25/100
- issue の種類
- 機能追加
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- typescript
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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.
- 主要言語
- TypeScript
- スター
- 9.8k
- フォーク
- 581
- 平均マージ
- 1日 14時間
- マージ済み PR(30日)
- 64
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
cloudflare/computer のほかの issue
-
Export one file helper over workspace.fs対応中かも @aron-cf が 16 日前に担当しました。 オープンenhancement
cloudflare/computer#261 · コメント 1 件 · リアクション 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
dofs: resolving a directory scans its children as the directory grows対応中かも @aron-cf が 23 日前に担当しました。 オープンbug
cloudflare/computer#236 · コメント 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
enhancement
難易度 4/5 3〜5日 初心者へのやさしさ 30/100
cloudflare/computer#224 · コメント 5 件 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Always run the "next" computerd workflow on push to the `release` branch再び着手できるかも @aron-cf が 28 日前に担当しましたが、オープン中のプルリクエストはありません。 オープンbug
cloudflare/computer#222 · コメント 2 件 · リアクション 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
Workspace namespaces: more than one Workspace per Durable Object storage再び着手できるかも @aron-cf が 28 日前に担当しましたが、オープン中のプルリクエストはありません。 オープンenhancement
cloudflare/computer#219 · コメント 1 件 · リアクション 1 件 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
cloudflare/computer の issue をすべて見る
似ている issue
-
Navbar avatar and name stay stale after saving profile changes対応中かも @alexechoi が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
marmelab/atomic-crm#416 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
epam/ai-dial-react-file-manager#99 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
canonical/craft-actions#146 ·
メンテナーはふだん 2 日以内に返信
-
feature-request
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
bluesky-social/social-app#12343 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
lucide-icons/lucide#5165 ·
メンテナーはふだん 1 日以内に返信