Support returning objects, functions and streams from custom modules

未关闭
#297 2 条评论 1 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
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 source Proxy over a host call(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 host query(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 import codemode:jobs-host and call waitRun({...}) 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"),
  • maxCapabilityCalls and maxCapabilityBytes,
  • the call's deadline and cancellation when the execution ends,
  • a callId to 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
    with unsafeToRepeat(), 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
    (its callId, signal, deadline and interrupt()), 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] (or using) 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 an Error with 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 小时
30 天内合并 PR
64

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

cloudflare/computer 的其他 Issue

查看 cloudflare/computer 的全部 Issue

相似的 Issue

更多 TypeScript Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。