diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0d23a1b12cc..d7c5787d9c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,6 +89,12 @@ jobs: working-directory: packages/codemode run: bun run script/publish.ts --dry-run + - name: Verify packed workerd SDK + if: runner.os == 'Linux' + timeout-minutes: 15 + working-directory: packages/sdk + run: bun run verify:package + - name: Verify compiled service lifecycle if: always() timeout-minutes: 10 diff --git a/packages/core/script/build.ts b/packages/core/script/build.ts index bf8eec926e4..ec62b1d9c1a 100644 --- a/packages/core/script/build.ts +++ b/packages/core/script/build.ts @@ -32,3 +32,38 @@ const result = await Bun.build({ }, }) if (!result.success) throw new AggregateError(result.logs, "Failed to build Core") + +// Bun's Node target eagerly creates its shared require helper, so every split +// entry evaluates import.meta.url even when it never requires a module. Keep +// the helper lazy until Bun stops hoisting it into workerd-reachable chunks. +// https://github.com/oven-sh/bun/issues/12615 +const eagerRequire = "var __require = /* @__PURE__ */ createRequire(import.meta.url);" +const lazyRequire = `var __require = (specifier) => createRequire(import.meta.url ?? "file:///worker.js")(specifier); +__require.resolve = (specifier, options) => createRequire(import.meta.url ?? "file:///worker.js").resolve(specifier, options);` +const rewritten = await Promise.all( + result.outputs.map(async (output) => { + if (!output.path.endsWith(".js")) return false + const source = await output.text() + + const generatedUses = source + .replace(/import\s*\{[^}]*\b__require\b[^}]*\}\s*from\s*["'][^"']+["'];/g, "") + .replace(/export\s*\{[^}]*\b__require\b[^}]*\};/g, "") + .replace(eagerRequire, "") + if (/\bnew\s+__require\s*\(/.test(generatedUses)) + throw new Error(`Unsupported generated require constructor in ${output.path}`) + const unsupported = generatedUses + .replace(/\b__require\.resolve\s*\(/g, "") + .replace(/\b__require\s*\(/g, "") + if (/\b__require\b/.test(unsupported)) throw new Error(`Unsupported generated require usage in ${output.path}`) + + if (!source.includes(eagerRequire)) return false + if (source.indexOf(eagerRequire) !== source.lastIndexOf(eagerRequire)) + throw new Error(`Multiple eager require helpers in ${output.path}`) + const rewrittenSource = source.replace(eagerRequire, lazyRequire) + if (rewrittenSource.includes(eagerRequire)) throw new Error(`Failed to rewrite eager require helper in ${output.path}`) + await Bun.write(output.path, rewrittenSource) + return true + }), +) +if (rewritten.filter(Boolean).length !== 1) + throw new Error("Expected exactly one eager require helper; Bun may have fixed #12615 and made this shim removable") diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0ac247deac7..b5533ea56ec 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -572,7 +572,7 @@ function cacheKey(source: string) { } export function bodyDigest(text: string) { - return new Bun.CryptoHasher("sha256").update(text).digest("hex") + return Hash.sha256(text) } export const layer = (options?: Options) => diff --git a/packages/core/src/session/message.ts b/packages/core/src/session/message.ts index ef3d7f4e560..d1d7189b1db 100644 --- a/packages/core/src/session/message.ts +++ b/packages/core/src/session/message.ts @@ -1,2 +1,2 @@ -export * as SessionMessage from "./message.js" +export * as SessionMessage from "@opencode-ai/schema/session-message" export * from "@opencode-ai/schema/session-message" diff --git a/packages/core/src/session/schema.ts b/packages/core/src/session/schema.ts index ba66b0ded05..bba2b61e9ea 100644 --- a/packages/core/src/session/schema.ts +++ b/packages/core/src/session/schema.ts @@ -1,4 +1,4 @@ -export * as SessionSchema from "./schema.js" +export * as SessionSchema from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 90bd79d4bd7..1b33d5ca72d 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -22,7 +22,8 @@ "scripts": { "build": "bun run script/build.ts", "test": "bun test --timeout 5000", - "typecheck": "tsgo -b" + "typecheck": "tsgo -b", + "verify:package": "bun run script/verify-package.ts" }, "dependencies": { "@opencode-ai/client": "workspace:*", diff --git a/packages/sdk/script/verify-package.ts b/packages/sdk/script/verify-package.ts new file mode 100644 index 00000000000..3545e78d990 --- /dev/null +++ b/packages/sdk/script/verify-package.ts @@ -0,0 +1,179 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("../../..", import.meta.url)) +const names = ["schema", "codemode", "ai", "util", "protocol", "client", "plugin", "core", "simulation", "server", "sdk"] +const temporary = await mkdtemp(join(tmpdir(), "opencode-sdk-package-")) +const archives = new Map() + +try { + for (const name of names) { + const directory = join(root, "packages", name) + await $`bun run build`.cwd(directory) + const original = await Bun.file(join(directory, "package.json")).text() + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- package manifests are validated by their package builds. + const pkg = JSON.parse(original) as { + name: string + dependencies?: Record + exports?: Record + imports?: Record> + } + const archive = join(temporary, `${name}.tgz`) + + if (pkg.dependencies) { + const unpacked = Object.keys(pkg.dependencies).filter( + (dependency) => dependency.startsWith("@opencode-ai/") && !archives.has(dependency), + ) + if (unpacked.length > 0) throw new Error(`${pkg.name} has unpacked workspace dependencies: ${unpacked.join(", ")}`) + pkg.dependencies = Object.fromEntries( + Object.entries(pkg.dependencies).map(([dependency, version]) => { + const local = archives.get(dependency) + return [dependency, local ? `file:${local}` : version] + }), + ) + } + if (pkg.exports) { + pkg.exports = Object.fromEntries( + Object.entries(pkg.exports).map(([key, value]) => { + if (typeof value !== "string") return [key, value] + return [key, { import: output(name, value), types: output(name, value, true) }] + }), + ) + } + if (pkg.imports) { + pkg.imports = Object.fromEntries( + Object.entries(pkg.imports).map(([key, conditions]) => [ + key, + Object.fromEntries( + Object.entries(conditions).map(([condition, value]) => [condition, output(name, value, condition === "types")]), + ), + ]), + ) + } + + await Bun.write(join(directory, "package.json"), JSON.stringify(pkg, null, 2) + "\n") + try { + await $`bun pm pack --filename ${archive} --ignore-scripts --quiet`.cwd(directory) + } finally { + await Bun.write(join(directory, "package.json"), original) + } + archives.set(pkg.name, archive) + } + + const consumer = join(temporary, "consumer") + await Bun.write( + join(consumer, "package.json"), + JSON.stringify({ name: "opencode-sdk-consumer", private: true, type: "module" }), + ) + await Promise.all([ + Bun.write( + join(consumer, "wrangler.jsonc"), + JSON.stringify({ + name: "opencode-sdk-packed-consumer", + main: "worker.js", + compatibility_date: "2026-07-15", + compatibility_flags: ["nodejs_compat"], + durable_objects: { bindings: [{ name: "OPENCODE", class_name: "OpenCodeDO" }] }, + migrations: [{ tag: "v1", new_sqlite_classes: ["OpenCodeDO"] }], + }), + ), + Bun.write( + join(consumer, "worker.js"), + `import { bodyDigest } from "@opencode-ai/core/models-dev" +import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd" +import { Effect } from "effect" + +export class OpenCodeDO { + constructor(state) { + this.state = state + } + + fetch() { + if (bodyDigest("packed-workerd") !== "5fc174bf63e8dd108ebb6c53d85e7bbc4525b2f4c1c43280364cdbfd9b37aaf5") { + throw new Error("Packed workerd SHA-256 mismatch") + } + const storage = this.state.storage + return Effect.runPromise( + Effect.gen(function* () { + const sdk = yield* OpenCodeWorkerd.create({ + storage, + app: { version: "packed-workerd" }, + config: { content: "{}" }, + }) + return Response.json(yield* sdk.health.get()) + }).pipe(Effect.scoped), + ) + } +} + +export default { + fetch(request, env) { + return env.OPENCODE.get(env.OPENCODE.idFromName("packed-consumer")).fetch(request) + }, +} +`, + ), + Bun.write( + join(consumer, "boot.mjs"), + `import { Miniflare } from "miniflare" + +const miniflare = new Miniflare({ + compatibilityDate: "2026-07-15", + compatibilityFlags: ["nodejs_compat"], + modules: true, + scriptPath: new URL("./dist/worker.js", import.meta.url).pathname, + durableObjects: { OPENCODE: { className: "OpenCodeDO", useSQLite: true } }, +}) + +try { + const response = await miniflare.dispatchFetch("http://opencode.local/health") + if (response.status !== 200) throw new Error( + "Packed workerd health returned " + response.status + ": " + await response.text(), + ) + const body = await response.json() + if (body.healthy !== true || body.version !== "packed-workerd") { + throw new Error("Unexpected packed workerd health: " + JSON.stringify(body)) + } +} finally { + await miniflare.dispose() +} +`, + ), + ]) + + const sdk = archives.get("@opencode-ai/sdk") + if (!sdk) throw new Error("Packed SDK archive was not created") + await $`npm install --ignore-scripts --no-audit --no-fund --package-lock=false ${sdk} wrangler@4.110.0`.cwd(consumer) + await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer) + + const transpiler = new Bun.Transpiler({ loader: "js" }) + const bundled = await Bun.file(join(consumer, "dist/worker.js")).text() + if (/createRequire\s*\(\s*import\.meta\.url\s*\)/.test(bundled)) { + throw new Error("Packed workerd bundle contains Bun's eager Node require initializer") + } + const bunGlobals = Array.from(new Set(bundled.match(/\bBun\.[A-Za-z_$][\w$]*/g) ?? [])) + if (bunGlobals.length > 0) throw new Error(`Packed workerd bundle references Bun globals: ${bunGlobals.join(", ")}`) + const leaked = [ + ...transpiler.scanImports(bundled) + .filter((imported) => imported.kind !== "dynamic-import") + .map((imported) => imported.path), + ...Array.from(bundled.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g), (match) => match[1]), + ] + .filter((specifier) => specifier === "bun" || specifier.startsWith("bun:")) + if (leaked.length > 0) throw new Error(`Packed workerd bundle statically imports Bun builtins: ${leaked.join(", ")}`) + + await $`node boot.mjs`.cwd(consumer) + console.log("packed SDK consumer OK") +} finally { + await rm(temporary, { recursive: true, force: true }) +} + +function output(name: string, value: string, types = false) { + const root = name === "core" && types ? "./dist/types/" : "./dist/" + return value.replace("./src/", root).replace(/\.ts$/, types ? ".d.ts" : ".js") +} diff --git a/packages/util/package.json b/packages/util/package.json index 993c23d4018..c61568d7d0a 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -25,7 +25,7 @@ "default": "./src/global-roots.ts" }, "#runtime-import": { - "workerd": "./src/runtime/import.bun.ts", + "workerd": "./src/runtime/import.workerd.ts", "bun": "./src/runtime/import.bun.ts", "node": "./src/runtime/import.node.ts", "default": "./src/runtime/import.bun.ts" diff --git a/packages/util/src/runtime/import.workerd.ts b/packages/util/src/runtime/import.workerd.ts new file mode 100644 index 00000000000..7fb345348a6 --- /dev/null +++ b/packages/util/src/runtime/import.workerd.ts @@ -0,0 +1,9 @@ +const unavailable = () => new Error("Dynamic module loading is unavailable on workerd") + +export function importModule(_specifier: string): Promise { + return Promise.reject(unavailable()) +} + +export function resolveModule(_specifier: string, _directory: string): string { + throw unavailable() +}