feat(core): workerd runtime stubs for bundles without a process

Resolve the native-module import conditions (#pty, #fff, #photon-wasm,
#shell-parser-wasm, #process-lock-ffi) to inert workerd stubs, so the module
graph loads in a runtime with no subprocesses, FFI, or filesystem artifacts.
Loopback OAuth servers import node:http lazily for the same reason, MCP gains
an stdio flag for runtimes that cannot spawn local servers, and Global roots
every path under one writable directory (tmp on workerd, OPENCODE_GLOBAL_ROOT
anywhere).
This commit is contained in:
Kit Langton
2026-08-11 21:41:51 -04:00
parent 70853b1e5b
commit c680feda35
13 changed files with 231 additions and 57 deletions
+5
View File
@@ -36,26 +36,31 @@
"default": "./src/database/sqlite.node.ts"
},
"#pty": {
"workerd": "./src/pty/pty.workerd.ts",
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
"node": "./src/filesystem/fff.node.ts",
"default": "./src/filesystem/fff.bun.ts"
},
"#photon-wasm": {
"workerd": "./src/image/photon-wasm.workerd.ts",
"bun": "./src/image/photon-wasm.bun.ts",
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#shell-parser-wasm": {
"workerd": "./src/shell/parser-wasm.workerd.ts",
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"workerd": "./src/util/process-lock-ffi.workerd.ts",
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
+119
View File
@@ -0,0 +1,119 @@
import type {
DirItem,
DirSearchResult,
FileItem,
GrepCursor,
GrepMatch,
GrepResult,
InitOptions,
MixedItem,
MixedSearchResult,
SearchResult,
} from "@ff-labs/fff-node"
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export type Init = InitOptions
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export interface Grep {
items: GrepResult["items"]
totalMatched: number
totalFilesSearched: number
totalFiles: number
filteredFileCount: number
nextCursor: Cursor
regexFallbackError?: string
}
export interface Picker {
destroy(): void
isScanning(): boolean
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
refreshGitStatus(): Result<number>
fileSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
glob(
pattern: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<Search>
directorySearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<DirSearch>
mixedSearch(
query: string,
opts?: {
currentFile?: string
pageIndex?: number
pageSize?: number
},
): Result<MixedSearch>
grep(
query: string,
opts?: {
mode?: "plain" | "regex" | "fuzzy"
maxMatchesPerFile?: number
timeBudgetMs?: number
beforeContext?: number
afterContext?: number
cursor?: Cursor
pageSize?: number
},
): Result<Grep>
trackQuery(query: string, file: string): Result<boolean>
getHistoricalQuery(offset: number): Result<string | null>
}
// workerd cannot load the fff native binding; reporting unavailable makes
// FileSystemSearch fall back to its non-fff layer.
export function available() {
return false
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable on workerd runtime" }
}
export * as Fff from "./fff.workerd"
@@ -0,0 +1,4 @@
// workerd has no filesystem path to a photon wasm artifact. Image.Photon only
// reads this lazily and surfaces a typed ResizerUnavailableError when loading
// fails, so an empty path degrades cleanly instead of breaking module load.
export default ""
+7
View File
@@ -164,6 +164,8 @@ export const Options = Schema.Struct({
version: Schema.String,
}),
),
/** Set false on runtimes that cannot spawn child processes; local (stdio) servers report failed instead of connecting. */
stdio: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -502,6 +504,11 @@ export const layer = (options?: Options) =>
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
if (options?.stdio === false && entry.config.type === "local") {
entry.status = { status: "failed", error: "stdio MCP servers are unavailable in this runtime" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
+2 -1
View File
@@ -2,7 +2,6 @@ export * as MCPOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
@@ -152,6 +151,8 @@ export const authorize = (input: {
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== redirectPath) {
+2 -1
View File
@@ -1,4 +1,3 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
@@ -58,6 +57,8 @@ const browser = (app: App.Info) =>
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
+9
View File
@@ -0,0 +1,9 @@
import type { Proc } from "./pty"
export type { Disp, Exit, Opts, Proc } from "./pty"
// workerd cannot spawn processes; the Pty service surfaces this as a defect if
// a terminal is ever requested on this runtime.
export function spawn(): Proc {
throw new Error("Pseudo-terminals are unavailable on the workerd runtime")
}
@@ -0,0 +1,4 @@
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
// loads these lazily and degrades when initialization fails, so empty paths
// keep module load side-effect free instead of resolving from disk.
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
@@ -0,0 +1,15 @@
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
// workerd has no FFI and no cross-process file locking; a Durable Object is
// already single-threaded per instance, so nothing on this runtime should
// reach these.
export function lockDarwin(): LockResult {
throw new Error("Process locks are unavailable on the workerd runtime")
}
export function lockLinux(): LockResult {
throw new Error("Process locks are unavailable on the workerd runtime")
}
@@ -0,0 +1,47 @@
import { Database } from "bun:sqlite"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
// Emulates the Durable Object storage API over bun:sqlite so the workerd
// adapter and the workerd server profile can be verified without workerd or
// Cloudflare runtime dependencies. The real runtime is covered by the
// workerd-spike package, which boots inside an actual isolate.
export const makeDurableObjectStorage = (): DurableObjectStorage => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
return {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
}
+6 -50
View File
@@ -1,63 +1,19 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { Effect, Layer } from "effect"
import { SqlClient } from "effect/unstable/sql"
import { SqlError } from "effect/unstable/sql/SqlError"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { makeDurableObjectStorage } from "./fixture/durable-object-storage"
import { tempGlobalLayer } from "./fixture/global"
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
// be verified without workerd or Cloudflare runtime dependencies.
const makeFakeStorage = () => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
const storage: DurableObjectStorage = {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
return storage
}
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
describe("sqlite.workerd", () => {
test("executes statements with bindings and maps rows to records", async () => {
const rows = await run(
makeFakeStorage(),
makeDurableObjectStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
@@ -73,7 +29,7 @@ describe("sqlite.workerd", () => {
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
const rows = await run(
makeFakeStorage(),
makeDurableObjectStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
@@ -86,7 +42,7 @@ describe("sqlite.workerd", () => {
})
test("withTransaction commits on success and rolls back on failure", async () => {
const storage = makeFakeStorage()
const storage = makeDurableObjectStorage()
const count = await run(
storage,
Effect.gen(function* () {
@@ -109,7 +65,7 @@ describe("sqlite.workerd", () => {
test("nested withTransaction fails with SqlError", async () => {
const error = await run(
makeFakeStorage(),
makeDurableObjectStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
@@ -122,7 +78,7 @@ describe("sqlite.workerd", () => {
})
test("boots the full database layer with migrations over injected storage", async () => {
const storage = makeFakeStorage()
const storage = makeDurableObjectStorage()
const core = await import("@opencode-ai/core/database/database")
await Effect.runPromise(
Effect.scoped(
+1
View File
@@ -21,6 +21,7 @@
},
"imports": {
"#runtime-import": {
"workerd": "./src/runtime/import.bun.ts",
"bun": "./src/runtime/import.bun.ts",
"node": "./src/runtime/import.node.ts",
"default": "./src/runtime/import.bun.ts"
+10 -5
View File
@@ -7,11 +7,16 @@ import { Flock } from "./flock.js"
import { makeGlobalNode } from "./effect/app-node.js"
const app = "opencode"
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const config = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const tmp = path.join(os.tmpdir(), app)
// workerd has no home directory or XDG base dirs and only tmp is writable, so
// root every global path under one directory there. OPENCODE_GLOBAL_ROOT
// overrides the root on any runtime.
const workerd = (globalThis as { navigator?: { userAgent?: string } }).navigator?.userAgent === "Cloudflare-Workers"
const root = process.env.OPENCODE_GLOBAL_ROOT ?? (workerd ? path.join(os.tmpdir(), app) : undefined)
const data = root ? path.join(root, "data") : path.join(xdgData!, app)
const cache = root ? path.join(root, "cache") : path.join(xdgCache!, app)
const config = root ? path.join(root, "config") : path.join(xdgConfig!, app)
const state = root ? path.join(root, "state") : path.join(xdgState!, app)
const tmp = root ? path.join(root, "tmp") : path.join(os.tmpdir(), app)
const paths = {
get home() {