Support returning objects, functions and streams from custom modules

オープン
#297 コメント 2 件 リアクション 1 件 担当者 0 名 GitHub で見る

メンテナーはふだん 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 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時間
マージ済み PR(30日)
64

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

cloudflare/computer のほかの issue

cloudflare/computer の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。