Skip to content

Commit efa8f9f

Browse files
Apply PR #17227: refactor(provider): effectify ProviderAuthService
2 parents 77623e7 + 395a958 commit efa8f9f

7 files changed

Lines changed: 477 additions & 98 deletions

File tree

packages/opencode/src/project/instance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { Effect } from "effect"
12
import { Log } from "@/util/log"
23
import { Context } from "../util/context"
34
import { Project } from "./project"
45
import { State } from "./state"
56
import { iife } from "@/util/iife"
67
import { GlobalBus } from "@/bus/global"
78
import { Filesystem } from "@/util/filesystem"
9+
import * as InstanceState from "@/util/instance-state"
810

911
interface Context {
1012
directory: string
@@ -106,15 +108,15 @@ export const Instance = {
106108
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
107109
const directory = Filesystem.resolve(input.directory)
108110
Log.Default.info("reloading instance", { directory })
109-
await State.dispose(directory)
111+
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
110112
cache.delete(directory)
111113
const next = track(directory, boot({ ...input, directory }))
112114
emit(directory)
113115
return await next
114116
},
115117
async dispose() {
116118
Log.Default.info("disposing instance", { directory: Instance.directory })
117-
await State.dispose(Instance.directory)
119+
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
118120
cache.delete(Instance.directory)
119121
emit(Instance.directory)
120122
},
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
2+
import { Instance } from "@/project/instance"
3+
import { Plugin } from "../plugin"
4+
import { filter, fromEntries, map, pipe } from "remeda"
5+
import type { AuthOuathResult } from "@opencode-ai/plugin"
6+
import { NamedError } from "@opencode-ai/util/error"
7+
import * as Auth from "@/auth/service"
8+
import * as InstanceState from "@/util/instance-state"
9+
import { ProviderID } from "./schema"
10+
import z from "zod"
11+
12+
export type Method = {
13+
type: "oauth" | "api"
14+
label: string
15+
}
16+
17+
export type Authorization = {
18+
url: string
19+
method: "auto" | "code"
20+
instructions: string
21+
}
22+
23+
export const OauthMissing = NamedError.create(
24+
"ProviderAuthOauthMissing",
25+
z.object({
26+
providerID: ProviderID.zod,
27+
}),
28+
)
29+
30+
export const OauthCodeMissing = NamedError.create(
31+
"ProviderAuthOauthCodeMissing",
32+
z.object({
33+
providerID: ProviderID.zod,
34+
}),
35+
)
36+
37+
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
38+
39+
export type ProviderAuthError =
40+
| Auth.AuthServiceError
41+
| InstanceType<typeof OauthMissing>
42+
| InstanceType<typeof OauthCodeMissing>
43+
| InstanceType<typeof OauthCallbackFailed>
44+
45+
export namespace ProviderAuthService {
46+
export interface Service {
47+
/** Get available auth methods for each provider (e.g. OAuth, API key). */
48+
readonly methods: () => Effect.Effect<Record<string, Method[]>>
49+
50+
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
51+
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
52+
53+
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
54+
readonly callback: (input: {
55+
providerID: ProviderID
56+
method: number
57+
code?: string
58+
}) => Effect.Effect<void, ProviderAuthError>
59+
60+
/** Set an API key directly for a provider (no OAuth flow). */
61+
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
62+
}
63+
}
64+
65+
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
66+
"@opencode/ProviderAuth",
67+
) {
68+
static readonly layer = Layer.effect(
69+
ProviderAuthService,
70+
Effect.gen(function* () {
71+
const auth = yield* Auth.AuthService
72+
const state = yield* InstanceState.make({
73+
lookup: () =>
74+
Effect.promise(async () => {
75+
const methods = pipe(
76+
await Plugin.list(),
77+
filter((x) => x.auth?.provider !== undefined),
78+
map((x) => [x.auth!.provider, x.auth!] as const),
79+
fromEntries(),
80+
)
81+
return { methods, pending: {} as Record<string, AuthOuathResult> }
82+
}),
83+
})
84+
85+
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
86+
const x = yield* InstanceState.get(state)
87+
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
88+
})
89+
90+
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
91+
providerID: ProviderID
92+
method: number
93+
}) {
94+
const authHook = (yield* InstanceState.get(state)).methods[input.providerID]
95+
const method = authHook.methods[input.method]
96+
if (method.type !== "oauth") return
97+
const result = yield* Effect.promise(() => method.authorize())
98+
99+
const s = yield* InstanceState.get(state)
100+
s.pending[input.providerID] = result
101+
return {
102+
url: result.url,
103+
method: result.method,
104+
instructions: result.instructions,
105+
}
106+
})
107+
108+
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
109+
providerID: ProviderID
110+
method: number
111+
code?: string
112+
}) {
113+
const match = (yield* InstanceState.get(state)).pending[input.providerID]
114+
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
115+
116+
if (match.method === "code" && !input.code)
117+
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
118+
119+
const result = yield* Effect.promise(() =>
120+
match.method === "code" ? match.callback(input.code!) : match.callback(),
121+
)
122+
123+
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
124+
125+
if ("key" in result) {
126+
yield* auth.set(input.providerID, {
127+
type: "api",
128+
key: result.key,
129+
})
130+
}
131+
132+
if ("refresh" in result) {
133+
yield* auth.set(input.providerID, {
134+
type: "oauth",
135+
access: result.access,
136+
refresh: result.refresh,
137+
expires: result.expires,
138+
...(result.accountId ? { accountId: result.accountId } : {}),
139+
})
140+
}
141+
})
142+
143+
const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) {
144+
yield* auth.set(input.providerID, {
145+
type: "api",
146+
key: input.key,
147+
})
148+
})
149+
150+
return ProviderAuthService.of({
151+
methods,
152+
authorize,
153+
callback,
154+
api,
155+
})
156+
}),
157+
)
158+
159+
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
160+
}
Lines changed: 19 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
1-
import { Instance } from "@/project/instance"
2-
import { Plugin } from "../plugin"
3-
import { map, filter, pipe, fromEntries, mapValues } from "remeda"
1+
import { Effect, ManagedRuntime } from "effect"
42
import z from "zod"
3+
54
import { fn } from "@/util/fn"
6-
import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
7-
import { NamedError } from "@opencode-ai/util/error"
8-
import { Auth } from "@/auth"
5+
import * as S from "./auth-service"
96
import { ProviderID } from "./schema"
107

11-
export namespace ProviderAuth {
12-
const state = Instance.state(async () => {
13-
const methods = pipe(
14-
await Plugin.list(),
15-
filter((x) => x.auth?.provider !== undefined),
16-
map((x) => [x.auth!.provider, x.auth!] as const),
17-
fromEntries(),
18-
)
19-
return { methods, pending: {} as Record<string, AuthOuathResult> }
20-
})
8+
// Separate runtime: ProviderAuthService can't join the shared runtime because
9+
// runtime.ts → auth-service.ts → provider/auth.ts creates a circular import.
10+
// AuthService is stateless file I/O so the duplicate instance is harmless.
11+
const rt = ManagedRuntime.make(S.ProviderAuthService.defaultLayer)
2112

13+
function runPromise<A>(f: (service: S.ProviderAuthService.Service) => Effect.Effect<A, S.ProviderAuthError>) {
14+
return rt.runPromise(S.ProviderAuthService.use(f))
15+
}
16+
17+
export namespace ProviderAuth {
2218
export const Method = z
2319
.object({
2420
type: z.union([z.literal("oauth"), z.literal("api")]),
@@ -30,15 +26,7 @@ export namespace ProviderAuth {
3026
export type Method = z.infer<typeof Method>
3127

3228
export async function methods() {
33-
const s = await state().then((x) => x.methods)
34-
return mapValues(s, (x) =>
35-
x.methods.map(
36-
(y): Method => ({
37-
type: y.type,
38-
label: y.label,
39-
}),
40-
),
41-
)
29+
return runPromise((service) => service.methods())
4230
}
4331

4432
export const Authorization = z
@@ -57,19 +45,7 @@ export namespace ProviderAuth {
5745
providerID: ProviderID.zod,
5846
method: z.number(),
5947
}),
60-
async (input): Promise<Authorization | undefined> => {
61-
const auth = await state().then((s) => s.methods[input.providerID])
62-
const method = auth.methods[input.method]
63-
if (method.type === "oauth") {
64-
const result = await method.authorize()
65-
await state().then((s) => (s.pending[input.providerID] = result))
66-
return {
67-
url: result.url,
68-
method: result.method,
69-
instructions: result.instructions,
70-
}
71-
}
72-
},
48+
async (input): Promise<Authorization | undefined> => runPromise((service) => service.authorize(input)),
7349
)
7450

7551
export const callback = fn(
@@ -78,71 +54,18 @@ export namespace ProviderAuth {
7854
method: z.number(),
7955
code: z.string().optional(),
8056
}),
81-
async (input) => {
82-
const match = await state().then((s) => s.pending[input.providerID])
83-
if (!match) throw new OauthMissing({ providerID: input.providerID })
84-
let result
85-
86-
if (match.method === "code") {
87-
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
88-
result = await match.callback(input.code)
89-
}
90-
91-
if (match.method === "auto") {
92-
result = await match.callback()
93-
}
94-
95-
if (result?.type === "success") {
96-
if ("key" in result) {
97-
await Auth.set(input.providerID, {
98-
type: "api",
99-
key: result.key,
100-
})
101-
}
102-
if ("refresh" in result) {
103-
const info: Auth.Info = {
104-
type: "oauth",
105-
access: result.access,
106-
refresh: result.refresh,
107-
expires: result.expires,
108-
}
109-
if (result.accountId) {
110-
info.accountId = result.accountId
111-
}
112-
await Auth.set(input.providerID, info)
113-
}
114-
return
115-
}
116-
117-
throw new OauthCallbackFailed({})
118-
},
57+
async (input) => runPromise((service) => service.callback(input)),
11958
)
12059

12160
export const api = fn(
12261
z.object({
12362
providerID: ProviderID.zod,
12463
key: z.string(),
12564
}),
126-
async (input) => {
127-
await Auth.set(input.providerID, {
128-
type: "api",
129-
key: input.key,
130-
})
131-
},
132-
)
133-
134-
export const OauthMissing = NamedError.create(
135-
"ProviderAuthOauthMissing",
136-
z.object({
137-
providerID: ProviderID.zod,
138-
}),
139-
)
140-
export const OauthCodeMissing = NamedError.create(
141-
"ProviderAuthOauthCodeMissing",
142-
z.object({
143-
providerID: ProviderID.zod,
144-
}),
65+
async (input) => runPromise((service) => service.api(input)),
14566
)
14667

147-
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
68+
export import OauthMissing = S.OauthMissing
69+
export import OauthCodeMissing = S.OauthCodeMissing
70+
export import OauthCallbackFailed = S.OauthCallbackFailed
14871
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Effect, ScopedCache, Scope } from "effect"
2+
3+
import { Instance } from "@/project/instance"
4+
5+
const TypeId = Symbol.for("@opencode/InstanceState")
6+
7+
type Task = (key: string) => Effect.Effect<void>
8+
9+
const tasks = new Set<Task>()
10+
11+
export interface InstanceState<A, E = never, R = never> {
12+
readonly [TypeId]: typeof TypeId
13+
readonly cache: ScopedCache.ScopedCache<string, A, E, R>
14+
}
15+
16+
export const make = <A, E = never, R = never>(input: {
17+
lookup: (key: string) => Effect.Effect<A, E, R>
18+
release?: (value: A, key: string) => Effect.Effect<void>
19+
}): Effect.Effect<InstanceState<A, E, R>, never, R | Scope.Scope> =>
20+
Effect.gen(function* () {
21+
const cache = yield* ScopedCache.make<string, A, E, R>({
22+
capacity: Number.POSITIVE_INFINITY,
23+
lookup: (key) =>
24+
Effect.acquireRelease(input.lookup(key), (value) => (input.release ? input.release(value, key) : Effect.void)),
25+
})
26+
27+
const task: Task = (key) => ScopedCache.invalidate(cache, key)
28+
tasks.add(task)
29+
yield* Effect.addFinalizer(() => Effect.sync(() => void tasks.delete(task)))
30+
31+
return {
32+
[TypeId]: TypeId,
33+
cache,
34+
}
35+
})
36+
37+
export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
38+
39+
export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
40+
41+
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
42+
ScopedCache.invalidate(self.cache, Instance.directory)
43+
44+
export const dispose = (key: string) =>
45+
Effect.all(
46+
[...tasks].map((task) => task(key)),
47+
{ concurrency: "unbounded" },
48+
)

0 commit comments

Comments
 (0)