fix(tui): stabilize reload connection state
This commit is contained in:
parent
24ab17e718
commit
8dd993d25a
16 changed files with 224 additions and 19 deletions
16
.opencode/plugins/sample-agent.ts
Normal file
16
.opencode/plugins/sample-agent.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export default {
|
||||||
|
id: "sample-agent-plugin",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.agent.transform((agents) => {
|
||||||
|
agents.update("sample-plugin-agent", (agent) => {
|
||||||
|
agent.description = "Example subagent registered by .opencode/plugins/sample-agent.ts"
|
||||||
|
agent.mode = "subagent"
|
||||||
|
agent.prompt = [
|
||||||
|
"You are the sample plugin agent for this repository.",
|
||||||
|
"Use this agent to verify that local plugin auto-discovery can add agents.",
|
||||||
|
"Keep responses concise and explain which plugin registered you when asked.",
|
||||||
|
].join("\n")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ export default Runtime.handler(
|
||||||
createOpencodeClient({
|
createOpencodeClient({
|
||||||
baseUrl: HttpServer.formatAddress(address),
|
baseUrl: HttpServer.formatAddress(address),
|
||||||
headers: ServerAuth.headers({ password }),
|
headers: ServerAuth.headers({ password }),
|
||||||
}).v2.location.get(undefined, { throwOnError: true }),
|
}).v2.health.get({}),
|
||||||
)
|
)
|
||||||
if (input.service) yield* daemon.register(address)
|
if (input.service) yield* daemon.register(address)
|
||||||
const url = HttpServer.formatAddress(address)
|
const url = HttpServer.formatAddress(address)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ const PluginModule = Schema.Struct({
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const PluginPackage = Schema.Struct({
|
||||||
|
exports: Schema.optional(Schema.Unknown),
|
||||||
|
main: Schema.optional(Schema.String),
|
||||||
|
module: Schema.optional(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "config-plugin",
|
id: "config-plugin",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
|
@ -65,8 +71,30 @@ export const Plugin = define({
|
||||||
symlink: true,
|
symlink: true,
|
||||||
})
|
})
|
||||||
.pipe(Effect.orElseSucceed(() => []))
|
.pipe(Effect.orElseSucceed(() => []))
|
||||||
|
const directories = yield* fs
|
||||||
|
.glob("{plugin,plugins}/*", {
|
||||||
|
cwd: entry.path,
|
||||||
|
absolute: true,
|
||||||
|
include: "all",
|
||||||
|
dot: true,
|
||||||
|
symlink: true,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.flatMap((items) =>
|
||||||
|
Effect.filter(items, (item) => fs.isDir(item), {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.orElseSucceed(() => []),
|
||||||
|
)
|
||||||
|
const packages = yield* Effect.forEach(
|
||||||
|
directories.sort(),
|
||||||
|
(directory) => resolvePackageEntrypoint(fs, directory),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
|
||||||
files.sort()
|
files.sort()
|
||||||
for (const file of files) configured.push({ package: file })
|
for (const file of files) configured.push({ package: file })
|
||||||
|
for (const file of packages) configured.push({ package: file })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,3 +117,18 @@ export const Plugin = define({
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||||
|
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||||
|
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||||
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
|
)
|
||||||
|
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
|
||||||
|
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
|
||||||
|
|
||||||
|
return yield* Effect.forEach(entries, (entry) => {
|
||||||
|
if (!entry) return Effect.succeed(undefined)
|
||||||
|
const file = path.resolve(directory, entry)
|
||||||
|
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
|
||||||
|
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Formatter, Logger, type LogLevel } from "effect"
|
import { Formatter, Logger, type LogLevel } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
|
import { InstallationChannel, InstallationLocal } from "../installation/version"
|
||||||
import { runID } from "./shared"
|
import { runID } from "./shared"
|
||||||
|
|
||||||
function formatter(id: string = runID) {
|
function formatter(id: string = runID) {
|
||||||
|
|
@ -46,9 +47,14 @@ function format(input: unknown) {
|
||||||
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
|
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
|
export function file(local = InstallationLocal, channel = InstallationChannel) {
|
||||||
|
if (!local) return path.join(Global.Path.log, "opencode.log")
|
||||||
|
return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileLogger(target = file(), id: string = runID) {
|
||||||
// Do not set batchWindow to 0; it causes high idle CPU usage.
|
// Do not set batchWindow to 0; it causes high idle CPU usage.
|
||||||
return Logger.toFile(formatter(id), file, { flag: "a" })
|
return Logger.toFile(formatter(id), target, { flag: "a" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
|
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { define } from "./internal"
|
import { define } from "./internal"
|
||||||
|
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { EventV2 } from "../event"
|
import { EventV2 } from "../event"
|
||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
|
|
@ -45,6 +46,15 @@ function variants(model: ModelsDev.Model) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeVariants(model: ModelV2Info, next: ModelV2Info["variants"]) {
|
||||||
|
const existing = new Map(model.variants.map((variant) => [variant.id, variant]))
|
||||||
|
const nextIDs = new Set(next.map((variant) => variant.id))
|
||||||
|
model.variants = [
|
||||||
|
...next.map((variant) => existing.get(variant.id) ?? variant),
|
||||||
|
...model.variants.filter((variant) => !nextIDs.has(variant.id)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export const ModelsDevPlugin = define({
|
export const ModelsDevPlugin = define({
|
||||||
id: "models-dev",
|
id: "models-dev",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
|
@ -111,7 +121,7 @@ export const ModelsDevPlugin = define({
|
||||||
input: [...(model.modalities?.input ?? [])],
|
input: [...(model.modalities?.input ?? [])],
|
||||||
output: [...(model.modalities?.output ?? [])],
|
output: [...(model.modalities?.output ?? [])],
|
||||||
}
|
}
|
||||||
draft.variants = variants(model)
|
mergeVariants(draft, variants(model))
|
||||||
draft.time.released = released(model.release_date)
|
draft.time.released = released(model.release_date)
|
||||||
draft.cost = cost(model.cost)
|
draft.cost = cost(model.cost)
|
||||||
draft.status = model.status ?? "active"
|
draft.status = model.status ?? "active"
|
||||||
|
|
|
||||||
|
|
@ -142,11 +142,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||||
Object.assign(model.request.headers, config.headers)
|
Object.assign(model.request.headers, config.headers)
|
||||||
Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options)))
|
Object.assign(model.request.body, lowerer.request(withoutCredentials(config.options)))
|
||||||
if (config.variants !== undefined) {
|
if (config.variants !== undefined) {
|
||||||
model.variants = Object.entries(config.variants).map(([id, options]) => ({
|
for (const [id, options] of Object.entries(config.variants)) {
|
||||||
id: ModelV2.VariantID.make(id),
|
const variantID = ModelV2.VariantID.make(id)
|
||||||
headers: { ...(options.headers ?? {}) },
|
let existing = model.variants.find((item) => item.id === variantID)
|
||||||
body: lowerer.request(withoutCredentials(options)),
|
if (!existing) {
|
||||||
}))
|
existing = { id: variantID, headers: {}, body: {} }
|
||||||
|
model.variants.push(existing)
|
||||||
|
}
|
||||||
|
Object.assign(existing.headers, options.headers)
|
||||||
|
Object.assign(existing.body, lowerer.request(withoutCredentials(options)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.release_date !== undefined) {
|
if (config.release_date !== undefined) {
|
||||||
const released = Date.parse(config.release_date)
|
const released = Date.parse(config.release_date)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { define } from "@opencode-ai/plugin/v2/promise"
|
||||||
|
|
||||||
|
export default define({
|
||||||
|
id: "folder-plugin",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.agent.transform((agents) => {
|
||||||
|
agents.update("folder", (agent) => {
|
||||||
|
agent.description = "Loaded from plugin folder"
|
||||||
|
agent.mode = "subagent"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -234,6 +234,10 @@ describe("ConfigExternalPlugin", () => {
|
||||||
description: "Loaded from plugin directory",
|
description: "Loaded from plugin directory",
|
||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
})
|
})
|
||||||
|
expect(yield* waitForAgent(agents, "folder")).toMatchObject({
|
||||||
|
description: "Loaded from plugin folder",
|
||||||
|
mode: "subagent",
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
14
packages/core/test/logging.test.ts
Normal file
14
packages/core/test/logging.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||||
|
|
||||||
|
describe("Logging", () => {
|
||||||
|
test("uses a local-specific log file for local installs", () => {
|
||||||
|
expect(Logging.file(true, "local")).toBe(path.join(Global.Path.log, "opencode-local.log"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps non-local installs on the default log file", () => {
|
||||||
|
expect(Logging.file(false, "next")).toBe(path.join(Global.Path.log, "opencode.log"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -9,6 +9,27 @@
|
||||||
"id": "local",
|
"id": "local",
|
||||||
"name": "Local",
|
"name": "Local",
|
||||||
"env": [],
|
"env": [],
|
||||||
"models": {}
|
"models": {
|
||||||
|
"model": {
|
||||||
|
"id": "model",
|
||||||
|
"name": "Local Model",
|
||||||
|
"release_date": "2026-01-01",
|
||||||
|
"attachment": false,
|
||||||
|
"reasoning": true,
|
||||||
|
"temperature": true,
|
||||||
|
"tool_call": true,
|
||||||
|
"limit": { "context": 1000, "output": 100 },
|
||||||
|
"experimental": {
|
||||||
|
"modes": {
|
||||||
|
"high": {
|
||||||
|
"provider": { "body": { "reasoning_effort": "high" } }
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"provider": { "headers": { "x-variant": "max" }, "body": { "reasoning_effort": "max" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@ import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||||
import { Policy } from "@opencode-ai/core/policy"
|
import { Policy } from "@opencode-ai/core/policy"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { location } from "../fixture/location"
|
import { location } from "../fixture/location"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
@ -73,4 +75,48 @@ describe("ModelsDevPlugin", () => {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("loads models.dev variants without replacing existing variants", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.sync(() => {
|
||||||
|
const previous = {
|
||||||
|
path: Flag.OPENCODE_MODELS_PATH,
|
||||||
|
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
|
||||||
|
}
|
||||||
|
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
|
||||||
|
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||||
|
return previous
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const service = yield* Catalog.Service
|
||||||
|
const integrations = yield* Integration.Service
|
||||||
|
yield* service.transform((catalog) => {
|
||||||
|
catalog.model.update(ProviderV2.ID.make("local"), ModelV2.ID.make("model"), (model) => {
|
||||||
|
model.variants = [
|
||||||
|
{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} },
|
||||||
|
{ id: ModelV2.VariantID.make("custom"), headers: {}, body: { custom: true } },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
yield* ModelsDevPlugin.effect(
|
||||||
|
host({
|
||||||
|
catalog: catalogHost(service),
|
||||||
|
integration: integrationHost(integrations),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((yield* service.model.get(ProviderV2.ID.make("local"), ModelV2.ID.make("model")))?.variants).toEqual([
|
||||||
|
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
|
||||||
|
expect.objectContaining({ id: "max", headers: { "x-variant": "max" }, body: { reasoning_effort: "max" } }),
|
||||||
|
expect.objectContaining({ id: "custom", body: { custom: true } }),
|
||||||
|
])
|
||||||
|
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
||||||
|
(previous) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
Flag.OPENCODE_MODELS_PATH = previous.path
|
||||||
|
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,15 @@ describe("OpencodePlugin", () => {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((draft) => {
|
yield* catalog.transform((draft) => {
|
||||||
draft.provider.update(ProviderV2.ID.make("remote"), () => {})
|
draft.provider.update(ProviderV2.ID.make("remote"), () => {})
|
||||||
|
draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"), (model) => {
|
||||||
|
model.variants = [
|
||||||
|
{
|
||||||
|
id: ModelV2.VariantID.make("custom"),
|
||||||
|
headers: { "x-custom": "true" },
|
||||||
|
body: { custom: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
|
draft.model.update(ProviderV2.ID.make("remote"), ModelV2.ID.make("stale"), () => {})
|
||||||
})
|
})
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
|
|
@ -176,6 +185,11 @@ describe("OpencodePlugin", () => {
|
||||||
})
|
})
|
||||||
expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 })
|
expect(model.request.body).toEqual({ custom: "value", temperature: 0.5 })
|
||||||
expect(model.variants).toEqual([
|
expect(model.variants).toEqual([
|
||||||
|
{
|
||||||
|
id: ModelV2.VariantID.make("custom"),
|
||||||
|
headers: { "x-custom": "true" },
|
||||||
|
body: { custom: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("high"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
headers: {},
|
headers: {},
|
||||||
|
|
|
||||||
|
|
@ -1148,7 +1148,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
<Show when={!startup.skipInitialLoading}>
|
<Show when={!startup.skipInitialLoading}>
|
||||||
<StartupLoading ready={ready} />
|
<StartupLoading ready={ready} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={sdk.connection.status() === "reconnecting"}>
|
<Show when={sdk.connection.status() === "connecting"}>
|
||||||
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
|
<Reconnecting attempt={sdk.connection.attempt()} error={sdk.connection.error()} />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
})
|
})
|
||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
|
let bootstrapping: Promise<void> | undefined
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
|
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
|
||||||
|
|
@ -543,6 +544,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||||
// so the mcp list refreshes here rather than off integration.updated.
|
// so the mcp list refreshes here rather than off integration.updated.
|
||||||
case "mcp.status.changed":
|
case "mcp.status.changed":
|
||||||
|
if (bootstrapping) break
|
||||||
void result.location.mcp.refresh(event.location)
|
void result.location.mcp.refresh(event.location)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -750,7 +752,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const settled = await Promise.allSettled([
|
if (bootstrapping) return bootstrapping
|
||||||
|
bootstrapping = Promise.allSettled([
|
||||||
sdk.api.session
|
sdk.api.session
|
||||||
.list({
|
.list({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
|
|
@ -787,14 +790,23 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
result.location.skill.refresh(),
|
result.location.skill.refresh(),
|
||||||
result.shell.refresh(),
|
result.shell.refresh(),
|
||||||
])
|
])
|
||||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
.then((settled) => {
|
||||||
console.error("Failed to refresh default location data", failure.reason)
|
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||||
|
console.error("Failed to refresh default location data", failure.reason)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
bootstrapping = undefined
|
||||||
|
})
|
||||||
|
return bootstrapping
|
||||||
}
|
}
|
||||||
|
|
||||||
onCleanup(
|
onCleanup(
|
||||||
sdk.event.listen(({ details }) => {
|
sdk.event.listen(({ details }) => {
|
||||||
|
if (details.type === "server.connected") {
|
||||||
|
void bootstrap()
|
||||||
|
return
|
||||||
|
}
|
||||||
handleEvent(details)
|
handleEvent(details)
|
||||||
if (details.type === "server.connected") void bootstrap()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
||||||
export type SDKConnectionStatus = "connecting" | "connected" | "reconnecting"
|
export type SDKConnectionStatus = "connected" | "connecting"
|
||||||
|
|
||||||
type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
|
type SDKEventMap = { [Type in V2Event["type"]]: Extract<V2Event, { type: Type }> }
|
||||||
const connectTimeout = 2_000
|
const connectTimeout = 2_000
|
||||||
|
|
@ -64,10 +64,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
return connection.signal.reason instanceof Error
|
return connection.signal.reason instanceof Error
|
||||||
? connection.signal.reason
|
? connection.signal.reason
|
||||||
: new Error("Event stream disconnected")
|
: new Error("Event stream disconnected")
|
||||||
|
if (first.value.type !== "server.connected") return new Error("Event stream did not start with server.connected")
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
|
||||||
events.emit(first.value.type, first.value)
|
events.emit(first.value.type, first.value)
|
||||||
|
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||||
connected()
|
connected()
|
||||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||||
const event = await iterator.next()
|
const event = await iterator.next()
|
||||||
|
|
@ -84,7 +85,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
if (abort.signal.aborted || controller.signal.aborted) return
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
attempt += 1
|
attempt += 1
|
||||||
setConnection({
|
setConnection({
|
||||||
status: "reconnecting",
|
status: "connecting",
|
||||||
attempt,
|
attempt,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||||
expect(data.connection.attempt()).toBe(0)
|
expect(data.connection.attempt()).toBe(0)
|
||||||
|
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
await wait(() => data.connection.status() === "reconnecting")
|
await wait(() => data.connection.status() === "connecting")
|
||||||
expect(data.connection.attempt()).toBe(1)
|
expect(data.connection.attempt()).toBe(1)
|
||||||
expect(data.connection.error()).toBe("Event stream disconnected")
|
expect(data.connection.error()).toBe("Event stream disconnected")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue