Skip to content

Commit 8b5940b

Browse files
Apply PR #18452: fix: lazy runtime imports in facades to break bundle cycles
2 parents 5dfe53f + e07589e commit 8b5940b

52 files changed

Lines changed: 484 additions & 568 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/opencode/specs/effect-migration.md

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,35 +46,64 @@ Rules:
4646
- Export `defaultLayer` only when wiring dependencies is useful
4747
- Use the direct namespace form once the module is fully migrated
4848

49-
## Temporary mixed-mode pattern
49+
## Service / Facade split
5050

51-
Prefer a single namespace whenever possible.
51+
Migrated services are split into two files:
5252

53-
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles.
53+
- **Service module** (`service.ts`, `*-service.ts`, or `*-effect.ts`) — contains `Interface`, `Service`, `layer`, `defaultLayer`, schemas, types, errors, and pure helpers. Must **never** import `@/effect/runtime`.
54+
- **Facade** (`index.ts`) — thin async wrapper that calls `runInstance()` or `run()` from `@/effect/run`. Contains **only** runtime-backed convenience functions. No re-exports of schemas, types, Service, layer, or anything else.
5455

55-
```ts
56-
export namespace FooEffect {
57-
export interface Interface {
58-
readonly get: (id: FooID) => Effect.Effect<Foo, FooError>
59-
}
56+
### Facade rules (critical for bundle safety)
6057

61-
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
58+
1. **No eager import of `@/effect/runtime`** — use `run()` / `runInstance()` from `@/effect/run` instead, which lazy-imports the runtime.
59+
2. **No eager import of the service module** if the service is in the circular dependency SCC (auth, account, skill, truncate). Use the lazy `svc()` pattern:
60+
```ts
61+
const svc = () => import("./service").then((m) => m.Foo.Service)
62+
```
63+
3. **No value re-exports** — consumers that need schemas, types, `Service`, or `layer` import from the service module directly.
64+
4. **Only async wrapper functions** — each function awaits `svc()` and passes an Effect to `run()` / `runInstance()`.
6265

63-
export const layer = Layer.effect(...)
64-
}
65-
```
66+
### Why
67+
68+
Bun's bundler flattens all modules into a single file. When a circular dependency exists (`runtime → instances → services → config → auth → runtime`), the bundler picks an arbitrary evaluation order. If a facade eagerly imports `@/effect/runtime` or re-exports values from a service in the SCC, those values may be `undefined` when accessed at module load time — causing `undefined is not an object` crashes.
6669

67-
Then keep the old boundary thin:
70+
The lazy `svc()` + `run()` pattern defers all access to call time, when all modules have finished initializing.
71+
72+
### Example facade
6873

6974
```ts
70-
export namespace Foo {
71-
export function get(id: FooID) {
72-
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
75+
// src/question/index.ts (facade)
76+
import { runInstance } from "@/effect/run"
77+
import type { Question as S } from "./service"
78+
79+
const svc = () => import("./service").then((m) => m.Question.Service)
80+
81+
export namespace Question {
82+
export async function ask(input: { ... }): Promise<S.Answer[]> {
83+
return runInstance((await svc()).use((s) => s.ask(input)))
84+
}
85+
86+
export async function list() {
87+
return runInstance((await svc()).use((s) => s.list()))
7388
}
7489
}
7590
```
7691

77-
Remove the `Effect` suffix when the boundary split is gone.
92+
### Current facades
93+
94+
| Facade | Service module | Scope |
95+
|---|---|---|
96+
| `src/question/index.ts` | `src/question/service.ts` | instance |
97+
| `src/permission/index.ts` | `src/permission/service.ts` | instance |
98+
| `src/format/index.ts` | `src/format/service.ts` | instance |
99+
| `src/file/index.ts` | `src/file/service.ts` | instance |
100+
| `src/file/time.ts` | `src/file/time-service.ts` | instance |
101+
| `src/provider/auth.ts` | `src/provider/auth-service.ts` | instance |
102+
| `src/skill/index.ts` | `src/skill/service.ts` | instance |
103+
| `src/snapshot/index.ts` | `src/snapshot/service.ts` | instance |
104+
| `src/auth/index.ts` | `src/auth/effect.ts` | global |
105+
| `src/account/index.ts` | `src/account/effect.ts` | global |
106+
| `src/tool/truncate.ts` | `src/tool/truncate-effect.ts` | global |
78107

79108
## Scheduled Tasks
80109

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,27 @@
1-
import { Effect, Option } from "effect"
2-
3-
import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
1+
import { Option } from "effect"
2+
import { run } from "@/effect/run"
3+
import { lazy } from "@/util/lazy"
4+
import { type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
45

56
export { AccessToken, AccountID, OrgID } from "./effect"
67

7-
import { runtime } from "@/effect/runtime"
8-
9-
function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
10-
return runtime.runSync(S.Service.use(f))
11-
}
12-
13-
function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
14-
return runtime.runPromise(S.Service.use(f))
15-
}
8+
const svc = lazy(() => import("./effect").then((m) => m.Account.Service))
169

1710
export namespace Account {
1811
export const Info = Model
1912
export type Info = Model
2013

21-
export function active(): Info | undefined {
22-
return Option.getOrUndefined(runSync((service) => service.active()))
14+
export async function active(): Promise<Info | undefined> {
15+
return Option.getOrUndefined(await run((await svc()).use((s) => s.active())))
2316
}
2417

2518
export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
26-
const config = await runPromise((service) => service.config(accountID, orgID))
19+
const config = await run((await svc()).use((s) => s.config(accountID, orgID)))
2720
return Option.getOrUndefined(config)
2821
}
2922

3023
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
31-
const token = await runPromise((service) => service.token(accountID))
24+
const token = await run((await svc()).use((s) => s.token(accountID)))
3225
return Option.getOrUndefined(token)
3326
}
3427
}

packages/opencode/src/agent/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
55
import { generateObject, streamObject, type ModelMessage } from "ai"
66
import { SystemPrompt } from "../session/system"
77
import { Instance } from "../project/instance"
8-
import { Truncate } from "../tool/truncate"
8+
import { Truncate } from "../tool/truncate-effect"
99
import { Auth } from "../auth"
1010
import { ProviderTransform } from "../provider/transform"
1111

packages/opencode/src/auth/index.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { Effect } from "effect"
21
import z from "zod"
3-
import { runtime } from "@/effect/runtime"
4-
import * as S from "./effect"
2+
import { run } from "@/effect/run"
3+
import { lazy } from "@/util/lazy"
54

65
export { OAUTH_DUMMY_KEY } from "./effect"
76

8-
function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
9-
return runtime.runPromise(S.Auth.Service.use(f))
10-
}
7+
const svc = lazy(() => import("./effect").then((m) => m.Auth.Service))
118

129
export namespace Auth {
1310
export const Oauth = z
@@ -40,18 +37,18 @@ export namespace Auth {
4037
export type Info = z.infer<typeof Info>
4138

4239
export async function get(providerID: string) {
43-
return runPromise((service) => service.get(providerID))
40+
return run((await svc()).use((s) => s.get(providerID)))
4441
}
4542

4643
export async function all(): Promise<Record<string, Info>> {
47-
return runPromise((service) => service.all())
44+
return run((await svc()).use((s) => s.all()))
4845
}
4946

5047
export async function set(key: string, info: Info) {
51-
return runPromise((service) => service.set(key, info))
48+
return run((await svc()).use((s) => s.set(key, info)))
5249
}
5350

5451
export async function remove(key: string) {
55-
return runPromise((service) => service.remove(key))
52+
return run((await svc()).use((s) => s.remove(key)))
5653
}
5754
}

packages/opencode/src/cli/cmd/account.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { cmd } from "./cmd"
22
import { Duration, Effect, Match, Option } from "effect"
33
import { UI } from "../ui"
4-
import { runtime } from "@/effect/runtime"
4+
import { run } from "@/effect/run"
55
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
66
import { type AccountError } from "@/account/schema"
77
import * as Prompt from "../effect/prompt"
@@ -160,7 +160,7 @@ export const LoginCommand = cmd({
160160
}),
161161
async handler(args) {
162162
UI.empty()
163-
await runtime.runPromise(loginEffect(args.url))
163+
await run(loginEffect(args.url))
164164
},
165165
})
166166

@@ -174,7 +174,7 @@ export const LogoutCommand = cmd({
174174
}),
175175
async handler(args) {
176176
UI.empty()
177-
await runtime.runPromise(logoutEffect(args.email))
177+
await run(logoutEffect(args.email))
178178
},
179179
})
180180

@@ -183,7 +183,7 @@ export const SwitchCommand = cmd({
183183
describe: false,
184184
async handler() {
185185
UI.empty()
186-
await runtime.runPromise(switchEffect())
186+
await run(switchEffect())
187187
},
188188
})
189189

@@ -192,7 +192,7 @@ export const OrgsCommand = cmd({
192192
describe: false,
193193
async handler() {
194194
UI.empty()
195-
await runtime.runPromise(orgsEffect())
195+
await run(orgsEffect())
196196
},
197197
})
198198

packages/opencode/src/cli/cmd/debug/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
77
import { MessageID, PartID } from "../../../session/schema"
88
import { ToolRegistry } from "../../../tool/registry"
99
import { Instance } from "../../../project/instance"
10-
import { PermissionNext } from "../../../permission"
10+
import { Permission as PermissionNext } from "../../../permission/service"
1111
import { iife } from "../../../util/iife"
1212
import { bootstrap } from "../../bootstrap"
1313
import { cmd } from "../cmd"

packages/opencode/src/cli/cmd/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
1111
import { Server } from "../../server/server"
1212
import { Provider } from "../../provider/provider"
1313
import { Agent } from "../../agent/agent"
14-
import { PermissionNext } from "../../permission"
14+
import { Permission as PermissionNext } from "../../permission/service"
1515
import { Tool } from "../../tool/tool"
1616
import { GlobTool } from "../../tool/glob"
1717
import { GrepTool } from "../../tool/grep"

packages/opencode/src/config/config.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import fs from "fs/promises"
1111
import { lazy } from "../util/lazy"
1212
import { NamedError } from "@opencode-ai/util/error"
1313
import { Flag } from "../flag/flag"
14-
import { Auth } from "../auth"
1514
import { Env } from "../env"
1615
import {
1716
type ParseError as JsoncParseError,
@@ -30,11 +29,13 @@ import { GlobalBus } from "@/bus/global"
3029
import { Event } from "../server/event"
3130
import { Glob } from "../util/glob"
3231
import { iife } from "@/util/iife"
33-
import { Account } from "@/account"
3432
import { ConfigPaths } from "./paths"
3533
import { Filesystem } from "@/util/filesystem"
3634
import { Npm } from "@/npm"
3735

36+
import { Auth } from "../auth"
37+
import { Account } from "@/account"
38+
3839
export namespace Config {
3940
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
4041

@@ -72,7 +73,7 @@ export namespace Config {
7273
}
7374

7475
export const state = Instance.state(async () => {
75-
const auth = await Auth.all()
76+
const entries = await Auth.all()
7677

7778
// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
7879
// 1) Remote .well-known/opencode (org defaults)
@@ -83,7 +84,7 @@ export namespace Config {
8384
// 6) Inline config (OPENCODE_CONFIG_CONTENT)
8485
// Managed config directory is enterprise-only and always overrides everything above.
8586
let result: Info = {}
86-
for (const [key, value] of Object.entries(auth)) {
87+
for (const [key, value] of Object.entries(entries)) {
8788
if (value.type === "wellknown") {
8889
const url = key.replace(/\/+$/, "")
8990
process.env[value.key] = value.token
@@ -172,13 +173,10 @@ export namespace Config {
172173
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
173174
}
174175

175-
const active = Account.active()
176+
const active = await Account.active()
176177
if (active?.active_org_id) {
177178
try {
178-
const [config, token] = await Promise.all([
179-
Account.config(active.id, active.active_org_id),
180-
Account.token(active.id),
181-
])
179+
const [config, token] = await Promise.all([Account.config(active.id, active.active_org_id), Account.token(active.id)])
182180
if (token) {
183181
process.env["OPENCODE_CONSOLE_TOKEN"] = token
184182
Env.set("OPENCODE_CONSOLE_TOKEN", token)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { Effect } from "effect"
2+
import type { GlobalServices } from "@/effect/runtime"
3+
import type { InstanceServices } from "@/effect/instances"
4+
import { lazy } from "@/util/lazy"
5+
6+
/**
7+
* Lazy wrappers that defer the import of @/effect/runtime to call time.
8+
*
9+
* Adapter modules must not eagerly import @/effect/runtime — or even
10+
* their own service modules — because bun's bundler can evaluate them
11+
* before their dependencies have finished initializing.
12+
*/
13+
14+
const runtime = lazy(() => import("@/effect/runtime"))
15+
16+
/** For global services (Auth, Account, etc.) */
17+
export async function run<A, E>(effect: Effect.Effect<A, E, GlobalServices>): Promise<A> {
18+
return (await runtime()).runtime.runPromise(effect)
19+
}
20+
21+
/** For instance-scoped services (Skill, Snapshot, Question, etc.) */
22+
export async function runInstance<A, E>(effect: Effect.Effect<A, E, InstanceServices>): Promise<A> {
23+
return (await runtime()).runPromiseInstance(effect)
24+
}

packages/opencode/src/effect/runtime.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { Installation } from "@/installation"
77
import { Truncate } from "@/tool/truncate-effect"
88
import { Instance } from "@/project/instance"
99

10+
export type GlobalServices = Account.Service | Installation.Service | Truncate.Service | Instances | Auth.Service
11+
1012
export const runtime = ManagedRuntime.make(
1113
Layer.mergeAll(
12-
Account.defaultLayer, //
14+
Account.defaultLayer,
1315
Installation.defaultLayer,
1416
Truncate.defaultLayer,
1517
Instances.layer,

0 commit comments

Comments
 (0)