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:
@@ -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:",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 })),
|
||||
}),
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user