Support returning objects, functions and streams from custom modules

Aperta
#297 2 commenti 1 reazione 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
25/100
Tipo di issue
Funzionalità
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
typescript

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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.
Lingua principale
TypeScript
Stelle
9.8k
Fork
581
Merge medio
1g 14h
PR unite (30g)
64

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di cloudflare/computer

Tutte le issue di cloudflare/computer

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.