From 02f2cf439e58d3f3db2e7927ecad9c0d4e1bca16 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 21:18:36 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20namespace=20=E2=86=92=20flat=20export?= =?UTF-8?q?=20migration=20(Bus=20proof-of-concept)=20(#22685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/script/unwrap-namespace.ts | 190 ++++++++ .../specs/effect/namespace-treeshake.md | 444 ++++++++++++++++++ packages/opencode/src/bus/bus.ts | 192 ++++++++ packages/opencode/src/bus/index.ts | 195 +------- 4 files changed, 827 insertions(+), 194 deletions(-) create mode 100644 packages/opencode/script/unwrap-namespace.ts create mode 100644 packages/opencode/specs/effect/namespace-treeshake.md create mode 100644 packages/opencode/src/bus/bus.ts diff --git a/packages/opencode/script/unwrap-namespace.ts b/packages/opencode/script/unwrap-namespace.ts new file mode 100644 index 0000000000..65ce498be8 --- /dev/null +++ b/packages/opencode/script/unwrap-namespace.ts @@ -0,0 +1,190 @@ +#!/usr/bin/env bun +/** + * Unwrap a TypeScript `export namespace` into flat exports + barrel. + * + * Usage: + * bun script/unwrap-namespace.ts src/bus/index.ts + * bun script/unwrap-namespace.ts src/bus/index.ts --dry-run + * + * What it does: + * 1. Reads the file and finds the `export namespace Foo { ... }` block + * (uses ast-grep for accurate AST-based boundary detection) + * 2. Removes the namespace wrapper and dedents the body + * 3. If the file is index.ts, renames it to .ts + * 4. Creates/updates index.ts with `export * as Foo from "./"` + * 5. Prints the import rewrite commands to run across the codebase + * + * Does NOT auto-rewrite imports — prints the commands so you can review them. + * + * Requires: ast-grep (`brew install ast-grep` or `cargo install ast-grep`) + */ + +import path from "path" +import fs from "fs" + +const args = process.argv.slice(2) +const dryRun = args.includes("--dry-run") +const filePath = args.find((a) => !a.startsWith("--")) + +if (!filePath) { + console.error("Usage: bun script/unwrap-namespace.ts [--dry-run]") + process.exit(1) +} + +const absPath = path.resolve(filePath) +if (!fs.existsSync(absPath)) { + console.error(`File not found: ${absPath}`) + process.exit(1) +} + +const src = fs.readFileSync(absPath, "utf-8") +const lines = src.split("\n") + +// Use ast-grep to find the namespace boundaries accurately. +// This avoids false matches from braces in strings, templates, comments, etc. +const astResult = Bun.spawnSync( + ["ast-grep", "run", "--pattern", "export namespace $NAME { $$$BODY }", "--lang", "typescript", "--json", absPath], + { stdout: "pipe", stderr: "pipe" }, +) + +if (astResult.exitCode !== 0) { + console.error("ast-grep failed:", astResult.stderr.toString()) + process.exit(1) +} + +const matches = JSON.parse(astResult.stdout.toString()) as Array<{ + text: string + range: { start: { line: number; column: number }; end: { line: number; column: number } } + metaVariables: { single: Record; multi: Record> } +}> + +if (matches.length === 0) { + console.error("No `export namespace Foo { ... }` found in file") + process.exit(1) +} + +if (matches.length > 1) { + console.error(`Found ${matches.length} namespaces — this script handles one at a time`) + console.error("Namespaces found:") + for (const m of matches) console.error(` ${m.metaVariables.single.NAME.text} (line ${m.range.start.line + 1})`) + process.exit(1) +} + +const match = matches[0] +const nsName = match.metaVariables.single.NAME.text +const nsLine = match.range.start.line // 0-indexed +const closeLine = match.range.end.line // 0-indexed, the line with closing `}` + +console.log(`Found: export namespace ${nsName} { ... }`) +console.log(` Lines ${nsLine + 1}–${closeLine + 1} (${closeLine - nsLine + 1} lines)`) + +// Build the new file content: +// 1. Everything before the namespace declaration (imports, etc.) +// 2. The namespace body, dedented by one level (2 spaces) +// 3. Everything after the closing brace (rare, but possible) +const before = lines.slice(0, nsLine) +const body = lines.slice(nsLine + 1, closeLine) +const after = lines.slice(closeLine + 1) + +// Dedent: remove exactly 2 leading spaces from each line +const dedented = body.map((line) => { + if (line === "") return "" + if (line.startsWith(" ")) return line.slice(2) + return line // don't touch lines that aren't indented (shouldn't happen) +}) + +const newContent = [...before, ...dedented, ...after].join("\n") + +// Figure out file naming +const dir = path.dirname(absPath) +const basename = path.basename(absPath, ".ts") +const isIndex = basename === "index" + +// The implementation file name (lowercase namespace name if currently index.ts) +const implName = isIndex ? nsName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() : basename +const implFile = path.join(dir, `${implName}.ts`) +const indexFile = path.join(dir, "index.ts") + +// The barrel line +const barrelLine = `export * as ${nsName} from "./${implName}"\n` + +console.log("") +if (isIndex) { + console.log(`Plan: rename ${basename}.ts → ${implName}.ts, create new index.ts barrel`) +} else { + console.log(`Plan: rewrite ${basename}.ts in place, create index.ts barrel`) +} +console.log("") + +if (dryRun) { + console.log("--- DRY RUN ---") + console.log("") + console.log(`=== ${implName}.ts (first 30 lines) ===`) + newContent + .split("\n") + .slice(0, 30) + .forEach((l, i) => console.log(` ${i + 1}: ${l}`)) + console.log(" ...") + console.log("") + console.log(`=== index.ts ===`) + console.log(` ${barrelLine.trim()}`) +} else { + // Write the implementation file + if (isIndex) { + // Rename: write new content to implFile, then overwrite index.ts with barrel + fs.writeFileSync(implFile, newContent) + fs.writeFileSync(indexFile, barrelLine) + console.log(`Wrote ${implName}.ts (${newContent.split("\n").length} lines)`) + console.log(`Wrote index.ts (barrel)`) + } else { + // Rewrite in place, create index.ts + fs.writeFileSync(absPath, newContent) + if (fs.existsSync(indexFile)) { + // Append to existing barrel + const existing = fs.readFileSync(indexFile, "utf-8") + if (!existing.includes(`export * as ${nsName}`)) { + fs.appendFileSync(indexFile, barrelLine) + console.log(`Appended to existing index.ts`) + } else { + console.log(`index.ts already has ${nsName} export`) + } + } else { + fs.writeFileSync(indexFile, barrelLine) + console.log(`Wrote index.ts (barrel)`) + } + console.log(`Rewrote ${basename}.ts (${newContent.split("\n").length} lines)`) + } +} + +// Print the import rewrite guidance +const relDir = path.relative(path.resolve("src"), dir) + +console.log("") +console.log("=== Import rewrites ===") +console.log("") + +if (!isIndex) { + // Non-index files: imports like "../provider/provider" need to become "../provider" + const oldTail = `${relDir}/${basename}` + + console.log(`# Find all imports to rewrite:`) + console.log(`rg 'from.*${oldTail}' src/ --files-with-matches`) + console.log("") + + // Auto-rewrite with sed (safe: only rewrites the import path, not other occurrences) + console.log("# Auto-rewrite (review diff afterward):") + console.log(`rg -l 'from.*${oldTail}' src/ | xargs sed -i '' 's|${oldTail}"|${relDir}"|g'`) + console.log("") + console.log("# What changes:") + console.log(`# import { ${nsName} } from ".../${oldTail}"`) + console.log(`# import { ${nsName} } from ".../${relDir}"`) +} else { + console.log("# File was index.ts — import paths already resolve correctly.") + console.log("# No import rewrites needed!") +} + +console.log("") +console.log("=== Verify ===") +console.log("") +console.log("bun typecheck # from packages/opencode") +console.log("bun run test # run tests") diff --git a/packages/opencode/specs/effect/namespace-treeshake.md b/packages/opencode/specs/effect/namespace-treeshake.md new file mode 100644 index 0000000000..8a9cf94fd4 --- /dev/null +++ b/packages/opencode/specs/effect/namespace-treeshake.md @@ -0,0 +1,444 @@ +# Namespace → flat export migration + +Migrate `export namespace` to the `export * as` / flat-export pattern used by +effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect +conventions, LLM-friendliness for future migrations. + +## What changes and what doesn't + +The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`, +`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved. + +What changes is **how** the namespace is constructed — the TypeScript +`export namespace` keyword is replaced by `export * as` in a barrel file. This +is a mechanical change: unwrap the namespace body into flat exports, add a +one-line barrel. Consumers that import `{ Provider }` don't notice. + +Import paths actually get **nicer**. Today most consumers import from the +explicit file (`"../provider/provider"`). After the migration, each module has a +barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`: + +```ts +// BEFORE — points at the file directly +import { Provider } from "../provider/provider" + +// AFTER — resolves to provider/index.ts, same Provider namespace +import { Provider } from "../provider" +``` + +## Why this matters right now + +The CLI binary startup time (TOI) is too slow. Profiling shows we're loading +massive dependency graphs that are never actually used at runtime — because +bundlers cannot tree-shake TypeScript `export namespace` bodies. + +### The problem in one sentence + +`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but +importing `{ Provider }` from `provider.ts` forces the bundler to include **all +20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`, +`google-auth-library`, and every other top-level import in that 1709-line file. + +### Why `export namespace` defeats tree-shaking + +TypeScript compiles `export namespace Foo { ... }` to an IIFE: + +```js +// TypeScript output +export var Provider; +(function (Provider) { + Provider.ModelNotFoundError = NamedError.create(...) + // ... 1600 more lines of assignments ... +})(Provider || (Provider = {})) +``` + +This is **opaque to static analysis**. The bundler sees one big function call +whose return value populates an object. It cannot determine which properties are +used downstream, so it keeps everything. Every `import` statement at the top of +`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into +memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`. + +### What `export * as` does differently + +`export * as Provider from "./provider"` compiles to a static re-export. The +bundler knows the exact shape of `Provider` at compile time — it's the named +export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used +but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't +reference `createAnthropic` or any AI SDK import, and drop them. The namespace +object still exists at runtime — same API — but the bundler can see inside it. + +### Concrete impact + +The worst import chain in the codebase: + +``` +src/index.ts (entry point) + └── FormatError from src/cli/error.ts + ├── { Provider } from provider/provider.ts (1709 lines) + │ ├── 20+ @ai-sdk/* packages + │ ├── @aws-sdk/credential-providers + │ ├── google-auth-library + │ ├── gitlab-ai-provider, venice-ai-sdk-provider + │ └── fuzzysort, remeda, etc. + ├── { Config } from config/config.ts (1663 lines) + │ ├── jsonc-parser + │ ├── LSPServer (all server definitions) + │ └── Plugin, Auth, Env, Account, etc. + └── { MCP } from mcp/index.ts (930 lines) + ├── @modelcontextprotocol/sdk (3 transports) + └── open (browser launcher) +``` + +All of this gets pulled in to check `.isInstance()` on 6 error classes — code +that needs maybe 200 bytes total. This inflates the binary, increases startup +memory, and slows down initial module evaluation. + +### Why this also hurts memory + +Every module-level import is eagerly evaluated. Even with Bun's fast module +loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and +Google's auth library allocates objects, closures, and prototype chains that +persist for the lifetime of the process. Most CLI commands never use a provider +at all. + +## What effect-smol does + +effect-smol achieves tree-shakeable namespaced APIs via three structural choices. + +### 1. Each module is a separate file with flat named exports + +```ts +// Effect.ts — no namespace wrapper, just flat exports +export const gen: { ... } = internal.gen +export const fail: (error: E) => Effect = internal.fail +export const succeed: (value: A) => Effect = internal.succeed +// ... 230+ individual named exports +``` + +### 2. Barrel file uses `export * as` (not `export namespace`) + +```ts +// index.ts +export * as Effect from "./Effect.ts" +export * as Schema from "./Schema.ts" +export * as Stream from "./Stream.ts" +// ~134 modules +``` + +This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the +bundler knows the **exact shape** at compile time — it's the static export list +of that file. It can trace property accesses (`Effect.gen` → keep `gen`, +drop `timeout` if unused). With `export namespace`, the IIFE is opaque and +nothing can be dropped. + +### 3. `sideEffects: []` and deep imports + +```jsonc +// package.json +{ "sideEffects": [] } +``` + +Plus `"./*": "./src/*.ts"` in the exports map, enabling +`import * as Effect from "effect/Effect"` to bypass the barrel entirely. + +### 4. Errors as flat exports, not class declarations + +```ts +// Cause.ts +export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId +export interface NoSuchElementError extends YieldableError { ... } +export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError +export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError +``` + +Each error is 4 independent exports: TypeId, interface, constructor (as const), +type guard. All individually shakeable. + +## The plan + +The core migration is **Phase 1** — convert `export namespace` to +`export * as`. Once that's done, the bundler can tree-shake individual exports +within each module. You do NOT need to break things into subfiles for +tree-shaking to work — the bundler traces which exports you actually access on +the namespace object and drops the rest, including their transitive imports. + +Splitting errors/schemas into separate files (Phase 0) is optional — it's a +lower-risk warmup step that can be done before or after the main conversion, and +it provides extra resilience against bundler edge cases. But the big win comes +from Phase 1. + +### Phase 0 (optional): Pre-split errors into subfiles + +This is a low-risk warmup that provides immediate benefit even before the full +`export * as` conversion. It's optional because Phase 1 alone is sufficient for +tree-shaking. But it's a good starting point if you want incremental progress: + +**For each namespace that defines errors** (15 files, ~30 error classes total): + +1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error + definitions as top-level named exports: + + ```ts + // provider/errors.ts + import z from "zod" + import { NamedError } from "@opencode-ai/shared/util/error" + import { ProviderID, ModelID } from "./schema" + + export const ModelNotFoundError = NamedError.create( + "ProviderModelNotFoundError", + z.object({ + providerID: ProviderID.zod, + modelID: ModelID.zod, + suggestions: z.array(z.string()).optional(), + }), + ) + + export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod })) + ``` + +2. In the namespace file, re-export from the errors file to maintain backward + compatibility: + + ```ts + // provider/provider.ts — inside the namespace + export { ModelNotFoundError, InitError } from "./errors" + ``` + +3. Update `cli/error.ts` (and any other light consumers) to import directly: + + ```ts + // BEFORE + import { Provider } from "../provider/provider" + Provider.ModelNotFoundError.isInstance(input) + + // AFTER + import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors" + ProviderModelNotFoundError.isInstance(input) + ``` + +**Files to split (Phase 0):** + +| Current file | New errors file | Errors to extract | +| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError | +| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed | +| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts | +| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError | +| `mcp/index.ts` | `mcp/errors.ts` | Failed | +| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError | +| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError | +| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError | +| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError | +| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError | +| `storage/storage.ts` | `storage/errors.ts` | NotFoundError | +| `npm/index.ts` | `npm/errors.ts` | InstallFailedError | +| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError | +| `lsp/client.ts` | `lsp/errors.ts` | InitializeError | + +### Phase 1: The real migration — `export namespace` → `export * as` + +This is the phase that actually fixes tree-shaking. For each module: + +1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper, + keep all the members as top-level `export const` / `export function` / etc. +2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` → + `bus/bus.ts`), so the barrel can take `index.ts`. +3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"` + +The file structure change for a module that's currently a single file: + +``` +# BEFORE +provider/ + provider.ts ← 1709-line file with `export namespace Provider { ... }` + +# AFTER +provider/ + index.ts ← NEW: `export * as Provider from "./provider"` + provider.ts ← SAME file, same name, just unwrap the namespace +``` + +And the code change is purely removing the wrapper: + +```ts +// BEFORE: provider/provider.ts +export namespace Provider { + export class Service extends Context.Service<...>()("@opencode/Provider") {} + export const layer = Layer.effect(Service, ...) + export const ModelNotFoundError = NamedError.create(...) + export function parseModel(model: string) { ... } +} + +// AFTER: provider/provider.ts — identical exports, no namespace keyword +export class Service extends Context.Service<...>()("@opencode/Provider") {} +export const layer = Layer.effect(Service, ...) +export const ModelNotFoundError = NamedError.create(...) +export function parseModel(model: string) { ... } +``` + +```ts +// NEW: provider/index.ts +export * as Provider from "./provider" +``` + +Consumer code barely changes — import path gets shorter: + +```ts +// BEFORE +import { Provider } from "../provider/provider" + +// AFTER — resolves to provider/index.ts, same Provider object +import { Provider } from "../provider" +``` + +All access like `Provider.ModelNotFoundError`, `Provider.Service`, +`Provider.layer` works exactly as before. The difference is invisible to +consumers but lets the bundler see inside the namespace. + +**Once this is done, you don't need to break anything into subfiles for +tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only +depends on `NamedError` + `zod` + the schema file, and drops +`Provider.layer` + all 20 AI SDK imports when they're unused. This works because +`export * as` gives the bundler a static export list it can do inner-graph +analysis on — it knows which exports reference which imports. + +**Order of conversion** (by risk / size, do small modules first): + +1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each) +2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines) +3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project` +4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP` + +### Phase 2: Build configuration + +After the module structure supports tree-shaking: + +1. Add `"sideEffects": []` to `packages/opencode/package.json` (or + `"sideEffects": false`) — this is safe because our services use explicit + layer composition, not import-time side effects. +2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is + insufficient, evaluate whether the compiled binary path needs an esbuild + pre-pass. +3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls + — these are factory functions that return classes, and bundlers may not know + they're side-effect-free without the annotation. + +## Automation + +The transformation is scripted. From `packages/opencode`: + +```bash +bun script/unwrap-namespace.ts [--dry-run] +``` + +The script uses ast-grep for accurate AST-based namespace boundary detection +(no false matches from braces in strings/templates/comments), then: + +1. Removes the `export namespace Foo {` line and its closing `}` +2. Dedents the body by one indent level (2 spaces) +3. If the file is `index.ts`, renames it to `.ts` and creates a new + `index.ts` barrel +4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts` +5. Prints the exact commands to find and rewrite import paths + +### Walkthrough: converting a module + +Using `Provider` as an example: + +```bash +# 1. Preview what will change +bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run + +# 2. Apply the transformation +bun script/unwrap-namespace.ts src/provider/provider.ts + +# 3. Rewrite import paths (script prints the exact command) +rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g' + +# 4. Verify +bun typecheck +bun run test +``` + +**What changes on disk:** + +``` +# BEFORE +provider/ + provider.ts ← 1709 lines, `export namespace Provider { ... }` + +# AFTER +provider/ + index.ts ← NEW: `export * as Provider from "./provider"` + provider.ts ← same file, namespace unwrapped to flat exports +``` + +**What changes in consumer code:** + +```ts +// BEFORE +import { Provider } from "../provider/provider" + +// AFTER — shorter path, same Provider object +import { Provider } from "../provider" +``` + +All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.) +stays identical. + +### Two cases the script handles + +**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`) + +- Rewrites the file in place (unwrap + dedent) +- Creates `provider/index.ts` as the barrel +- Import paths change: `"../provider/provider"` → `"../provider"` + +**Case B: file IS `index.ts`** (e.g. `bus/index.ts`) + +- Renames `index.ts` → `bus.ts` (kebab-case of namespace name) +- Creates new `index.ts` as the barrel +- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts` + +## Do I need to split errors/schemas into subfiles? + +**No.** Once you do the `export * as` conversion, the bundler can tree-shake +individual exports within the file. If `cli/error.ts` only accesses +`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError` +doesn't reference `createAnthropic` and drops the AI SDK imports. + +Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code +organization** — smaller files are easier to read and review. But it's not +required for tree-shaking. The `export * as` conversion alone is sufficient. + +The one case where subfile splitting provides extra tree-shake value is if an +imported package has module-level side effects that the bundler can't prove are +unused. In practice this is rare — most npm packages are side-effect-free — and +adding `"sideEffects": []` to package.json handles the common cases. + +## Scope + +| Metric | Count | +| ----------------------------------------------- | --------------- | +| Files with `export namespace` | 106 | +| Total namespace declarations | 118 (12 nested) | +| Files with `NamedError.create` inside namespace | 15 | +| Total error classes to extract | ~30 | +| Files using `export * as` today | 0 | + +Phase 1 (the `export * as` conversion) is the main change. It's mechanical and +LLM-friendly but touches every import site, so it should be done module by +module with type-checking between each step. Each module is an independent PR. + +## Rules for new code + +Going forward: + +- **No new `export namespace`**. Use a file with flat named exports and + `export * as` in the barrel. +- Keep the service, layer, errors, schemas, and runtime wiring together in one + file if you want — that's fine now. The `export * as` barrel makes everything + individually shakeable regardless of file structure. +- If a file grows large enough that it's hard to navigate, split by concern + (errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the + bundler handles that. diff --git a/packages/opencode/src/bus/bus.ts b/packages/opencode/src/bus/bus.ts new file mode 100644 index 0000000000..c5e31e6c20 --- /dev/null +++ b/packages/opencode/src/bus/bus.ts @@ -0,0 +1,192 @@ +import z from "zod" +import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" +import { EffectBridge } from "@/effect/bridge" +import { Log } from "../util/log" +import { BusEvent } from "./bus-event" +import { GlobalBus } from "./global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" + +const log = Log.create({ service: "bus" }) + +export const InstanceDisposed = BusEvent.define( + "server.instance.disposed", + z.object({ + directory: z.string(), + }), +) + +type Payload = { + type: D["type"] + properties: z.infer +} + +type State = { + wildcard: PubSub.PubSub + typed: Map> +} + +export interface Interface { + readonly publish: ( + def: D, + properties: z.output, + ) => Effect.Effect + readonly subscribe: (def: D) => Stream.Stream> + readonly subscribeAll: () => Stream.Stream + readonly subscribeCallback: ( + def: D, + callback: (event: Payload) => unknown, + ) => Effect.Effect<() => void> + readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> +} + +export class Service extends Context.Service()("@opencode/Bus") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + Effect.fn("Bus.state")(function* (ctx) { + const wildcard = yield* PubSub.unbounded() + const typed = new Map>() + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + // Publish InstanceDisposed before shutting down so subscribers see it + yield* PubSub.publish(wildcard, { + type: InstanceDisposed.type, + properties: { directory: ctx.directory }, + }) + yield* PubSub.shutdown(wildcard) + for (const ps of typed.values()) { + yield* PubSub.shutdown(ps) + } + }), + ) + + return { wildcard, typed } + }), + ) + + function getOrCreate(state: State, def: D) { + return Effect.gen(function* () { + let ps = state.typed.get(def.type) + if (!ps) { + ps = yield* PubSub.unbounded() + state.typed.set(def.type, ps) + } + return ps as unknown as PubSub.PubSub> + }) + } + + function publish(def: D, properties: z.output) { + return Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const payload: Payload = { type: def.type, properties } + log.info("publishing", { type: def.type }) + + const ps = s.typed.get(def.type) + if (ps) yield* PubSub.publish(ps, payload) + yield* PubSub.publish(s.wildcard, payload) + + const dir = yield* InstanceState.directory + const context = yield* InstanceState.context + const workspace = yield* InstanceState.workspaceID + + GlobalBus.emit("event", { + directory: dir, + project: context.project.id, + workspace, + payload, + }) + }) + } + + function subscribe(def: D): Stream.Stream> { + log.info("subscribing", { type: def.type }) + return Stream.unwrap( + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + const ps = yield* getOrCreate(s, def) + return Stream.fromPubSub(ps) + }), + ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type })))) + } + + function subscribeAll(): Stream.Stream { + log.info("subscribing", { type: "*" }) + return Stream.unwrap( + Effect.gen(function* () { + const s = yield* InstanceState.get(state) + return Stream.fromPubSub(s.wildcard) + }), + ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" })))) + } + + function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { + return Effect.gen(function* () { + log.info("subscribing", { type }) + const bridge = yield* EffectBridge.make() + const scope = yield* Scope.make() + const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) + + yield* Scope.provide(scope)( + Stream.fromSubscription(subscription).pipe( + Stream.runForEach((msg) => + Effect.tryPromise({ + try: () => Promise.resolve().then(() => callback(msg)), + catch: (cause) => { + log.error("subscriber failed", { type, cause }) + }, + }).pipe(Effect.ignore), + ), + Effect.forkScoped, + ), + ) + + return () => { + log.info("unsubscribing", { type }) + bridge.fork(Scope.close(scope, Exit.void)) + } + }) + } + + const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* ( + def: D, + callback: (event: Payload) => unknown, + ) { + const s = yield* InstanceState.get(state) + const ps = yield* getOrCreate(s, def) + return yield* on(ps, def.type, callback) + }) + + const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { + const s = yield* InstanceState.get(state) + return yield* on(s.wildcard, "*", callback) + }) + + return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) + }), +) + +export const defaultLayer = layer + +const { runPromise, runSync } = makeRuntime(Service, layer) + +// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, +// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. +export async function publish(def: D, properties: z.output) { + return runPromise((svc) => svc.publish(def, properties)) +} + +export function subscribe( + def: D, + callback: (event: { type: D["type"]; properties: z.infer }) => unknown, +) { + return runSync((svc) => svc.subscribeCallback(def, callback)) +} + +export function subscribeAll(callback: (event: any) => unknown) { + return runSync((svc) => svc.subscribeAllCallback(callback)) +} diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 3a1eea5c73..3c21d7c7d1 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -1,194 +1 @@ -import z from "zod" -import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" -import { EffectBridge } from "@/effect/bridge" -import { Log } from "../util/log" -import { BusEvent } from "./bus-event" -import { GlobalBus } from "./global" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" - -export namespace Bus { - const log = Log.create({ service: "bus" }) - - export const InstanceDisposed = BusEvent.define( - "server.instance.disposed", - z.object({ - directory: z.string(), - }), - ) - - type Payload = { - type: D["type"] - properties: z.infer - } - - type State = { - wildcard: PubSub.PubSub - typed: Map> - } - - export interface Interface { - readonly publish: ( - def: D, - properties: z.output, - ) => Effect.Effect - readonly subscribe: (def: D) => Stream.Stream> - readonly subscribeAll: () => Stream.Stream - readonly subscribeCallback: ( - def: D, - callback: (event: Payload) => unknown, - ) => Effect.Effect<() => void> - readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> - } - - export class Service extends Context.Service()("@opencode/Bus") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const state = yield* InstanceState.make( - Effect.fn("Bus.state")(function* (ctx) { - const wildcard = yield* PubSub.unbounded() - const typed = new Map>() - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - // Publish InstanceDisposed before shutting down so subscribers see it - yield* PubSub.publish(wildcard, { - type: InstanceDisposed.type, - properties: { directory: ctx.directory }, - }) - yield* PubSub.shutdown(wildcard) - for (const ps of typed.values()) { - yield* PubSub.shutdown(ps) - } - }), - ) - - return { wildcard, typed } - }), - ) - - function getOrCreate(state: State, def: D) { - return Effect.gen(function* () { - let ps = state.typed.get(def.type) - if (!ps) { - ps = yield* PubSub.unbounded() - state.typed.set(def.type, ps) - } - return ps as unknown as PubSub.PubSub> - }) - } - - function publish(def: D, properties: z.output) { - return Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } - log.info("publishing", { type: def.type }) - - const ps = s.typed.get(def.type) - if (ps) yield* PubSub.publish(ps, payload) - yield* PubSub.publish(s.wildcard, payload) - - const dir = yield* InstanceState.directory - const context = yield* InstanceState.context - const workspace = yield* InstanceState.workspaceID - - GlobalBus.emit("event", { - directory: dir, - project: context.project.id, - workspace, - payload, - }) - }) - } - - function subscribe(def: D): Stream.Stream> { - log.info("subscribing", { type: def.type }) - return Stream.unwrap( - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - return Stream.fromPubSub(ps) - }), - ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type })))) - } - - function subscribeAll(): Stream.Stream { - log.info("subscribing", { type: "*" }) - return Stream.unwrap( - Effect.gen(function* () { - const s = yield* InstanceState.get(state) - return Stream.fromPubSub(s.wildcard) - }), - ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" })))) - } - - function on(pubsub: PubSub.PubSub, type: string, callback: (event: T) => unknown) { - return Effect.gen(function* () { - log.info("subscribing", { type }) - const bridge = yield* EffectBridge.make() - const scope = yield* Scope.make() - const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub)) - - yield* Scope.provide(scope)( - Stream.fromSubscription(subscription).pipe( - Stream.runForEach((msg) => - Effect.tryPromise({ - try: () => Promise.resolve().then(() => callback(msg)), - catch: (cause) => { - log.error("subscriber failed", { type, cause }) - }, - }).pipe(Effect.ignore), - ), - Effect.forkScoped, - ), - ) - - return () => { - log.info("unsubscribing", { type }) - bridge.fork(Scope.close(scope, Exit.void)) - } - }) - } - - const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* ( - def: D, - callback: (event: Payload) => unknown, - ) { - const s = yield* InstanceState.get(state) - const ps = yield* getOrCreate(s, def) - return yield* on(ps, def.type, callback) - }) - - const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { - const s = yield* InstanceState.get(state) - return yield* on(s.wildcard, "*", callback) - }) - - return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) - }), - ) - - export const defaultLayer = layer - - const { runPromise, runSync } = makeRuntime(Service, layer) - - // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, - // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. - export async function publish(def: D, properties: z.output) { - return runPromise((svc) => svc.publish(def, properties)) - } - - export function subscribe( - def: D, - callback: (event: { type: D["type"]; properties: z.infer }) => unknown, - ) { - return runSync((svc) => svc.subscribeCallback(def, callback)) - } - - export function subscribeAll(callback: (event: any) => unknown) { - return runSync((svc) => svc.subscribeAllCallback(callback)) - } -} +export * as Bus from "./bus"