feat(server): workerd runtime profile for durable objects

ServerWorkerd.create builds the fetch handler for a Durable Object's fetch(),
with every intentionally-local service replaced: the database runs on the
injected DO SQLite, plugin discovery is precompiled-only, MCP is remote-only,
Snapshot and Vcs degrade to empty results, and Shell/FileSystem/Pty fail with
a clear defect until a remote sandbox backs them.

Threading it through needs one seam: createRoutes and ServerFetch.make take
runtime-profile replacements applied after the standard set, so later entries
win. script/workerd-probe.ts pins that the graph bundles under the workerd
condition without bun builtins.
This commit is contained in:
Kit Langton
2026-08-11 21:41:52 -04:00
parent 29ddd370bd
commit 2913aee8ba
6 changed files with 273 additions and 6 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
},
"scripts": {
"test": "bun test --only-failures",
"typecheck": "tsgo -b"
"typecheck": "tsgo -b",
"probe:workerd": "bun run script/workerd-probe.ts"
},
"dependencies": {
"@effect/platform-node": "catalog:",
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bun
/**
* Bundle probe for the workerd profile: verifies the full module graph behind
* src/workerd.ts resolves under the `workerd` condition without any `bun:`
* builtins. `node:` builtins stay external (workerd provides them through
* nodejs_compat); the probe prints the surviving externals so the A4 boot
* spike knows exactly what the runtime must supply.
*/
import path from "node:path"
import { mkdtempSync } from "node:fs"
import os from "node:os"
const outdir = mkdtempSync(path.join(os.tmpdir(), "opencode-workerd-probe-"))
const result = await Bun.build({
entrypoints: [path.join(import.meta.dir, "../src/workerd.ts")],
conditions: ["workerd"],
target: "node",
outdir,
sourcemap: "none",
throw: false,
})
if (!result.success) {
console.error(`workerd bundle probe FAILED (${result.logs.length} issues)`)
for (const log of result.logs) console.error(String(log))
process.exit(1)
}
// Everything in the graph is bundled, so any import specifier that survives in
// the output is an external the runtime must provide.
const transpiler = new Bun.Transpiler({ loader: "js" })
const externals = new Set<string>()
for (const artifact of result.outputs) {
const text = await artifact.text()
for (const imported of transpiler.scanImports(text)) externals.add(imported.path)
for (const match of text.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)) externals.add(match[1])
}
const sorted = Array.from(externals).toSorted()
const bun = sorted.filter((specifier) => specifier === "bun" || specifier.startsWith("bun:"))
const bytes = result.outputs.reduce((total, artifact) => total + artifact.size, 0)
console.log(`workerd bundle probe OK: ${result.outputs.length} artifacts, ${(bytes / 1024 / 1024).toFixed(1)} MiB`)
console.log(`external builtins (${sorted.length}):`)
for (const specifier of sorted) console.log(` ${specifier}`)
if (bun.length > 0) {
console.error(`FAILED: bun builtins leaked into the workerd graph: ${bun.join(", ")}`)
process.exit(1)
}
+12 -2
View File
@@ -3,6 +3,7 @@ export * as ServerFetch from "./fetch"
import { Context, Effect, Layer } from "effect"
import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { isAllowedCorsOrigin } from "./cors"
import { createRoutes } from "./routes"
import type { ServerOptions } from "./options"
@@ -32,9 +33,18 @@ export interface BootOptions {
* Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it
* serves unauthenticated, so an embedder without a password must front the handler with its own
* access control.
*
* `overrides` are layer replacements applied after the standard set, so a runtime profile can
* swap services the standard graph assumes are local — see `ServerWorkerd.replacements`.
*/
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) {
const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices)))
export const make = Effect.fn("ServerFetch.make")(function* (
options: ServerOptions = {},
overrides: LayerNode.Replacements = [],
boot: BootOptions = {},
) {
const context = yield* Layer.build(
createRoutes(options, () => [], overrides).pipe(Layer.provide(HttpServer.layerServices)),
)
// Forked so the returned handler is never delayed; resumed drains are already
// logged and durably recorded by the execution layer.
if (boot.resumeSuspendedSessions)
+11 -3
View File
@@ -66,27 +66,34 @@ const applicationServices = LayerNode.group([
SessionRestart.node,
])
export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray<string> = () => []) {
export function createRoutes(
options: ServerOptions = {},
serviceURLs: () => ReadonlyArray<string> = () => [],
overrides: LayerNode.Replacements = [],
) {
return makeRoutes(
options.password
? ServerAuth.Config.configLayer({ password: Option.some(options.password) })
: ServerAuth.Config.layer,
options,
serviceURLs,
overrides,
)
}
export function createEmbeddedRoutes(options: ServerOptions = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [])
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], [])
}
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
options: ServerOptions,
serviceURLs: () => ReadonlyArray<string>,
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
overrides: LayerNode.Replacements,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
const standard: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
@@ -122,6 +129,7 @@ function makeRoutes<AuthError, AuthServices>(
WorkspaceDriver.registryNode({ [modalProvider]: modalWorkspaceDriver({ app: "opencode-workspaces" }) }),
],
]
const replacements: LayerNode.Replacements = [...standard, ...overrides]
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
+166
View File
@@ -0,0 +1,166 @@
export * as ServerWorkerd from "./workerd"
import { Effect, Layer } from "effect"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Database } from "@opencode-ai/core/database/database"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Pty } from "@opencode-ai/core/pty"
import { Shell } from "@opencode-ai/core/shell"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Vcs } from "@opencode-ai/core/vcs"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerFetch } from "./fetch"
import type { ServerOptions } from "./options"
/**
* The workerd runtime profile: boots opencode core and server inside a
* Cloudflare Durable Object, with every intentionally-local service replaced
* or disabled.
*
* - Database runs on the injected `DurableObjectStorage` SQLite.
* - Watcher and fff are disabled through their existing option flags; pty, fff,
* shell-parser, photon, and process-lock native modules resolve to inert
* stubs under the `workerd` bundle condition.
* - Shell, FileSystem, FileSystemSearch, and Pty fail with a clear defect until
* a remote sandbox backs them; Snapshot and Vcs degrade to no-op results.
* - Config is injected as a string (no filesystem); plugin discovery is
* precompiled-only and MCP is restricted to remote transports.
*
* Bundle with the `workerd` condition, e.g.
* `bun build src/workerd.ts --conditions=workerd --target=node`
* (see `script/workerd-probe.ts`).
*/
export interface Options {
/** Durable Object storage whose SQLite database backs the opencode database. */
readonly storage: DurableObjectStorage
readonly app?: ServerOptions["app"]
readonly password?: string
/** Inline opencode config content (JSON), same as `ServerOptions.config.content`. */
readonly config?: { readonly content?: string }
/** models.dev catalog source; the bundled snapshot is the boot-time floor either way. */
readonly models?: { readonly url?: string }
/** Overrides for the injected Global paths; defaults root everything under tmp on workerd. */
readonly paths?: Partial<Global.Interface>
}
/**
* Builds the web-standard fetch handler for a Durable Object's `fetch()`. The
* application layer builds eagerly in the caller's scope, so hold it in the
* Durable Object instance rather than per request.
*/
export function create(options: Options) {
// Eviction can kill the isolate between a turn's Started and terminal events with no
// teardown. The write-ahead execution claim plus this boot-time resume recovers such
// orphaned turns by replaying the drain from durable history on the next wake.
return ServerFetch.make(serverOptions(options), replacements(options), { resumeSuspendedSessions: true })
}
export function serverOptions(options: Options): ServerOptions {
return {
app: options.app,
password: options.password,
fs: { filewatcher: false, fff: false },
config: { content: options.config?.content },
models: { url: options.models?.url },
}
}
/** The workerd replacement graph, applied after the standard server replacements. */
export function replacements(options: Options): LayerNode.Replacements {
return [
[
Database.node,
makeGlobalNode({
service: Database.Service,
layer: Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage: options.storage }))),
deps: [Global.node],
}),
],
[Global.node, Global.layerWith({ ...options.paths })],
[Snapshot.node, Snapshot.noopLayer],
[Vcs.node, vcsLayer],
[Shell.node, shellLayer],
[FileSystem.node, fileSystemLayer],
[FileSystemSearch.node, fileSystemSearchLayer],
[Pty.node, ptyLayer],
[
MCP.node,
MCP.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
stdio: false,
}),
],
// Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm
// install, or import of plugin code from disk.
[ConfigPluginSource.node, ConfigPluginSource.empty],
] satisfies LayerNode.Replacements
}
const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailable in the workerd profile`))
// Vcs degrades to empty results, matching its behavior for locations without a
// supported VCS, so read-only clients never need to special-case this runtime.
const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
info: () => Effect.succeed({ branch: {} }),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
}),
)
// Shell commands need a real process; queries for unknown IDs stay typed while
// creation is a defect until a remote sandbox backs them.
const shellLayer = Layer.succeed(
Shell.Service,
Shell.Service.of({
name: () => Effect.succeed("unsupported"),
create: () => unavailable("Shell.create"),
list: () => Effect.succeed([]),
get: (id) => Effect.fail(new Shell.NotFoundError({ id })),
wait: (id) => Effect.fail(new Shell.NotFoundError({ id })),
timeout: (id) => Effect.fail(new Shell.NotFoundError({ id })),
output: (id) => Effect.fail(new Shell.NotFoundError({ id })),
remove: (id) => Effect.fail(new Shell.NotFoundError({ id })),
}),
)
// The Location-scoped filesystem has no local worktree to serve until a remote
// sandbox backs it.
const fileSystemLayer = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => unavailable("FileSystem.read"),
list: () => unavailable("FileSystem.list"),
find: () => unavailable("FileSystem.find"),
}),
)
const fileSystemSearchLayer = Layer.succeed(
FileSystemSearch.Service,
FileSystemSearch.Service.of({
find: () => unavailable("FileSystemSearch.find"),
}),
)
const ptyLayer = Layer.succeed(
Pty.Service,
Pty.Service.of({
list: () => Effect.succeed([]),
get: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
create: () => unavailable("Pty.create"),
update: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
remove: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
write: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
attach: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
}),
)
+34
View File
@@ -0,0 +1,34 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
import { it } from "../../core/test/lib/effect"
import { ServerWorkerd } from "../src/workerd"
// Covers the profile's replacement graph composing and the database booting
// through the injected Durable Object storage. Verification inside a real
// isolate lives in the workerd-spike package.
it.live("boots the workerd profile over durable object storage", () =>
Effect.gen(function* () {
const handler = yield* ServerWorkerd.create({
storage: makeDurableObjectStorage(),
password: "secret",
app: { version: "workerd-test" },
config: { content: "{}" },
})
const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
expect(unauthorized.status).toBe(401)
const health = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/health", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
)
expect(health.status).toBe(200)
const body: unknown = yield* Effect.promise(() => health.json())
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
)