@toist/aja 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -1,551 +1,561 @@
1
1
  // 2121
2
+ import type { Database } from "bun:sqlite"
2
3
  import { Hono } from "hono"
3
4
  import { cors } from "hono/cors"
4
5
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
5
6
  import { join } from "node:path"
6
- import { runtimeDb, dataDb, cacheDb, initDbs } from "./db-handles.ts"
7
- import { makeCache } from "./cache.ts"
8
- import { loadAll, watchAll, getPipelines, getPipeline, runPipeline, validateSpec, publicSpec, triggerSubRun, makeLogger, type RunOutcome } from "./pipeline.ts"
9
- import { parseYaml, type PlatformCtx } from "@toist/spec"
10
- import { manifest, getKind, type ExecContext } from "./kinds/index.ts"
11
- import { answerTask, getTask, loadNodeOutputs, persistNodeOutputs } from "./hitl.ts"
12
- import { loadInstance } from "./instance.ts"
13
7
  import {
14
- buildResourceCtx, listResourceTypes, listResources, getResource,
15
- upsertResource, patchResource, deleteResource,
8
+ getPipeline,
9
+ getPipelines,
10
+ getKind,
11
+ loadAll,
12
+ makeLogger,
13
+ manifest,
14
+ publicSpec,
15
+ runPipeline,
16
+ validateSpec,
17
+ type RunCtx,
18
+ type RunOutcome,
19
+ type ToistEvent,
20
+ type ToistRuntime,
21
+ } from "@toist/core"
22
+ import { parseYaml, type ExecContext, type PipelineSpec } from "@toist/spec"
23
+ import { loadInstance } from "./instance-metadata.ts"
24
+ import {
25
+ deleteResource,
26
+ getResource,
27
+ listResources,
28
+ listResourceTypes,
29
+ patchResource,
30
+ upsertResource,
16
31
  } from "./resources.ts"
17
- import { makeRunStore } from "./runs.ts"
18
- import { pipelinesDir, isUiDisabled, isWatchDisabled, getCorsOrigins } from "./config.ts"
32
+ import { createRunEventBroker, type RunEventBroker } from "./run-events.ts"
19
33
 
20
34
  const VALID_PIPELINE_ID = /^[a-z0-9][a-z0-9-]*$/
21
35
 
22
- // Legacy entry path: when bun runs this module directly (pm2 dev mode), do
23
- // the lifecycle init here. When startRunner imports this module, init has
24
- // already happened — initDbs() is idempotent so the guard below is belt-and-
25
- // suspenders. The conditional avoids unnecessary work when import.meta.main
26
- // is false (the startRunner-imported case).
27
- if (import.meta.main) {
28
- await initDbs()
36
+ export interface MountApiOptions {
37
+ rootDir: string
38
+ pipelinesDir?: string
39
+ adminDb?: Database
40
+ disableUi?: boolean
41
+ corsOrigins?: string | string[]
29
42
  }
30
43
 
31
- // apiApp holds every HTTP route. The outer `app` (declared at the bottom)
32
- // mounts apiApp at /api and owns CORS + future static UI serving. Per
33
- // instance-spec.md §9 + §12 Roadmap: routes live under /api/* so the UI
34
- // can mount cleanly at / when packaging lands.
35
- const apiApp = new Hono()
36
-
37
- const cache = makeCache(cacheDb())
38
-
39
- const PIPELINES_DIR = pipelinesDir()
40
- loadAll(PIPELINES_DIR)
41
- if (!isWatchDisabled()) {
42
- watchAll(PIPELINES_DIR)
44
+ function resolveRuntimeKind(runtime: ToistRuntime, id: string) {
45
+ if (!runtime.registry) return getKind(id)
46
+ const kind = runtime.registry.get(id)
47
+ if (kind) return kind
48
+ return runtime.registry.exclusive ? undefined : getKind(id)
43
49
  }
44
50
 
45
- // ─── instance metadata ───────────────────────────────────────────────────────
46
- apiApp.get("/", (c) => {
47
- const inst = loadInstance()
48
- return c.json({
49
- name: inst.instanceName ?? process.env.PLATFORM_INSTANCE_NAME ?? "platform",
50
- version: "0.1.0",
51
- platformVersion: inst.platformVersion,
52
- tier: inst.tier,
53
- pipelines: getPipelines().length,
54
- kinds: manifest().length,
55
- })
56
- })
51
+ function runtimeManifest(runtime: ToistRuntime) {
52
+ return runtime.registry ? runtime.registry.manifest() : manifest()
53
+ }
57
54
 
58
- // Per-instance metadata: tier, teasers, branding. Distinct from runtime
59
- // state in /runs and pipeline state in /pipelines.
60
- apiApp.get("/instance", (c) => c.json(loadInstance()))
55
+ export async function mountApi(runtime: ToistRuntime, options: MountApiOptions): Promise<Hono> {
56
+ const apiApp = new Hono()
57
+ const pipelinesDir = options.pipelinesDir ?? join(options.rootDir, "pipelines")
58
+ const runEvents = new Map<number, RunEventBroker>()
59
+ const adHocRuns = new Map<number, { spec: PipelineSpec; payload: Record<string, unknown> }>()
61
60
 
62
- // ─── kinds (capability) ──────────────────────────────────────────────────────
63
- apiApp.get("/manifest", (c) => c.json({ kinds: manifest() }))
61
+ apiApp.get("/", (c) => {
62
+ const inst = loadInstance(options.rootDir)
63
+ return c.json({
64
+ name: inst.instanceName ?? process.env.PLATFORM_INSTANCE_NAME ?? "platform",
65
+ version: "0.1.0",
66
+ platformVersion: inst.platformVersion,
67
+ tier: inst.tier,
68
+ pipelines: getPipelines().length,
69
+ kinds: runtimeManifest(runtime).length,
70
+ })
71
+ })
64
72
 
65
- apiApp.post("/kinds/:id/invoke", async (c) => {
66
- const kind = getKind(c.req.param("id"))
67
- if (!kind) return c.json({ error: "kind not found" }, 404)
73
+ apiApp.get("/instance", (c) => c.json(loadInstance(options.rootDir)))
68
74
 
69
- const body = await c.req.json().catch(() => ({}))
70
- const { params = {}, input = {}, confirm = false } = body as {
71
- params?: Record<string, unknown>
72
- input?: Record<string, unknown>
73
- confirm?: boolean
74
- }
75
+ apiApp.get("/manifest", (c) => c.json({
76
+ version: 1,
77
+ schema: "https://toist.in/schemas/manifest-v1.json",
78
+ kinds: runtimeManifest(runtime),
79
+ }))
75
80
 
76
- if (kind.sideEffect && !confirm) {
77
- return c.json({
78
- error: "side_effect_requires_confirm",
79
- message: `Kind "${kind.id}" mutates external state. Pass { confirm: true } to invoke ad-hoc.`,
80
- }, 400)
81
- }
81
+ apiApp.post("/kinds/:id/invoke", async (c) => {
82
+ const kind = resolveRuntimeKind(runtime, c.req.param("id"))
83
+ if (!kind) return c.json({ error: "kind not found" }, 404)
82
84
 
83
- const invokeCtx: ExecContext = {
84
- runId: -1, // synthetic invoke is not a tracked run
85
- db: dataDb(),
86
- cache,
87
- log: (level, msg) => console.log(`[invoke ${kind.id}] ${level}: ${msg}`),
88
- resource: buildResourceCtx(runtimeDb()),
89
- runs: makeRunStore(runtimeDb()),
90
- subRun: async () => {
91
- throw new Error("ctx.subRun is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
92
- },
93
- step: { nodeId: "<invoke>" },
94
- suspend: async () => {
95
- throw new Error("ctx.suspend is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
96
- },
97
- }
85
+ const body = await c.req.json().catch(() => ({}))
86
+ const { params = {}, input = {}, confirm = false } = body as {
87
+ params?: Record<string, unknown>
88
+ input?: Record<string, unknown>
89
+ confirm?: boolean
90
+ }
98
91
 
99
- const t0 = performance.now()
100
- try {
101
- const output = await kind.run(invokeCtx, params, input)
102
- return c.json({
103
- kind: kind.id,
104
- output,
105
- duration_ms: Math.round(performance.now() - t0),
106
- })
107
- } catch (err: unknown) {
108
- return c.json({
109
- kind: kind.id,
110
- error: err instanceof Error ? err.message : String(err),
111
- duration_ms: Math.round(performance.now() - t0),
112
- }, 500)
113
- }
114
- })
92
+ if (kind.sideEffect && !confirm) {
93
+ return c.json({
94
+ error: "side_effect_requires_confirm",
95
+ message: `Kind "${kind.id}" mutates external state. Pass { confirm: true } to invoke ad-hoc.`,
96
+ }, 400)
97
+ }
115
98
 
116
- // ─── resources ───────────────────────────────────────────────────────────────
99
+ const invokeCtx: ExecContext = {
100
+ runId: -1,
101
+ db: runtime.data,
102
+ cache: runtime.cache,
103
+ log: (level, msg) => console.log(`[invoke ${kind.id}] ${level}: ${msg}`),
104
+ resource: await runtime.resources.resolve(),
105
+ runs: runtime.runs,
106
+ subRun: async () => {
107
+ throw new Error("ctx.subRun is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
108
+ },
109
+ step: { nodeId: "<invoke>" },
110
+ suspend: async () => {
111
+ throw new Error("ctx.suspend is not available in ad-hoc kinds.invoke — call from inside a pipeline run instead.")
112
+ },
113
+ }
117
114
 
118
- apiApp.get("/resource-types", (c) => c.json(listResourceTypes()))
115
+ const t0 = performance.now()
116
+ try {
117
+ const output = await kind.run(invokeCtx, params, input)
118
+ return c.json({
119
+ kind: kind.id,
120
+ output,
121
+ duration_ms: Math.round(performance.now() - t0),
122
+ })
123
+ } catch (err: unknown) {
124
+ return c.json({
125
+ kind: kind.id,
126
+ error: err instanceof Error ? err.message : String(err),
127
+ duration_ms: Math.round(performance.now() - t0),
128
+ }, 500)
129
+ }
130
+ })
119
131
 
120
- apiApp.get("/resources", (c) => c.json(listResources(runtimeDb())))
132
+ apiApp.get("/resource-types", (c) => c.json(listResourceTypes()))
121
133
 
122
- apiApp.get("/resources/:name", (c) => {
123
- const r = getResource(runtimeDb(), c.req.param("name"))
124
- if (!r) return c.json({ error: "not found" }, 404)
125
- return c.json(r)
126
- })
134
+ apiApp.get("/resources", (c) => {
135
+ if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
136
+ return c.json(listResources(options.adminDb))
137
+ })
127
138
 
128
- apiApp.post("/resources", async (c) => {
129
- const body = await c.req.json().catch(() => ({})) as {
130
- name?: string; type?: string; fields?: Record<string, unknown>
131
- }
132
- if (!body.name || typeof body.name !== "string") return c.json({ error: "name required" }, 400)
133
- if (!body.type || typeof body.type !== "string") return c.json({ error: "type required" }, 400)
134
- const fields = body.fields ?? {}
135
- const r = upsertResource(runtimeDb(), body.name, body.type, fields)
136
- return c.json(r, 201)
137
- })
138
-
139
- apiApp.put("/resources/:name", async (c) => {
140
- const name = c.req.param("name")
141
- const body = await c.req.json().catch(() => ({})) as {
142
- type?: string; fields?: Record<string, unknown>
143
- }
144
- if (body.type !== undefined && body.fields !== undefined) {
145
- // Full replacement
146
- const r = upsertResource(runtimeDb(), name, body.type, body.fields)
147
- return c.json(r)
148
- }
149
- if (body.fields !== undefined) {
150
- // Patch fields only
151
- const r = patchResource(runtimeDb(), name, body.fields)
139
+ apiApp.get("/resources/:name", (c) => {
140
+ if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
141
+ const r = getResource(options.adminDb, c.req.param("name"))
152
142
  if (!r) return c.json({ error: "not found" }, 404)
153
143
  return c.json(r)
154
- }
155
- return c.json({ error: "provide type+fields for replacement or fields for patch" }, 400)
156
- })
157
-
158
- apiApp.delete("/resources/:name", (c) => {
159
- const ok = deleteResource(runtimeDb(), c.req.param("name"))
160
- if (!ok) return c.json({ error: "not found" }, 404)
161
- return new Response(null, { status: 204 })
162
- })
163
-
164
- // ─── pipelines ───────────────────────────────────────────────────────────────
165
- apiApp.get("/pipelines", (c) => {
166
- const list = getPipelines().map((p) => ({
167
- id: p.id,
168
- label: p.label ?? p.id,
169
- description: p.description,
170
- nodeCount: p.nodes.length,
171
- }))
172
- return c.json(list)
173
- })
174
-
175
- apiApp.get("/pipelines/:id", (c) => {
176
- const spec = getPipeline(c.req.param("id"))
177
- if (!spec) return c.json({ error: "not found" }, 404)
178
- return c.json(publicSpec(spec))
179
- })
180
-
181
- // Raw YAML source for editing. Returns the file as written to disk (preserves
182
- // comments and formatting), not the parsed spec.
183
- apiApp.get("/pipelines/:id/source", (c) => {
184
- const id = c.req.param("id")
185
- if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
186
- const path = join(PIPELINES_DIR, `${id}.yaml`)
187
- if (!existsSync(path)) return c.json({ error: "not found" }, 404)
188
- return c.json({ id, yaml: readFileSync(path, "utf8") })
189
- })
190
-
191
- // Create a new pipeline file. The id comes from the YAML's `id:` field — no
192
- // separate body.id, since two sources of truth caused silent overwrites.
193
- // Refuses to overwrite an existing id.
194
- apiApp.post("/pipelines", async (c) => {
195
- const body = await c.req.json().catch(() => ({})) as { yaml?: string }
196
- if (!body.yaml) return c.json({ error: "yaml required" }, 400)
197
-
198
- let parsed: unknown
199
- try { parsed = parseYaml(body.yaml) }
200
- catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
201
-
202
- const id = (parsed as { id?: string })?.id
203
- if (!id) return c.json({ error: "yaml must contain a top-level `id:` field" }, 400)
204
- if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: `invalid id "${id}" — lowercase alphanum + hyphens, must start with alphanum` }, 400)
205
-
206
- const path = join(PIPELINES_DIR, `${id}.yaml`)
207
- if (existsSync(path)) return c.json({ error: `pipeline "${id}" already exists` }, 409)
208
-
209
- const result = validateSpec(parsed)
210
- if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
211
-
212
- writeFileSync(path, body.yaml)
213
- // Sync reload — don't rely on the fs watcher, since the client may navigate
214
- // to the new pipeline immediately and would otherwise see a 404.
215
- loadAll(PIPELINES_DIR)
216
- return c.json({ id, ok: true }, 201)
217
- })
218
-
219
- // Update an existing pipeline file. Refuses to change the id (renames are a
220
- // separate operation we don't yet support).
221
- apiApp.put("/pipelines/:id", async (c) => {
222
- const id = c.req.param("id")
223
- if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
224
-
225
- const path = join(PIPELINES_DIR, `${id}.yaml`)
226
- if (!existsSync(path)) return c.json({ error: "not found" }, 404)
227
-
228
- const body = await c.req.json().catch(() => ({})) as { yaml?: string }
229
- if (!body.yaml) return c.json({ error: "yaml required" }, 400)
230
-
231
- let parsed: unknown
232
- try { parsed = parseYaml(body.yaml) }
233
- catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
234
-
235
- const yamlId = (parsed as { id?: string })?.id
236
- if (yamlId && yamlId !== id) {
237
- return c.json({ error: `id mismatch: url says "${id}", yaml says "${yamlId}". Renames are not supported via edit.` }, 400)
238
- }
144
+ })
239
145
 
240
- const result = validateSpec(parsed)
241
- if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
242
-
243
- writeFileSync(path, body.yaml)
244
- loadAll(PIPELINES_DIR)
245
- return c.json({ id, ok: true })
246
- })
247
-
248
- apiApp.post("/pipelines/validate", async (c) => {
249
- const body = await c.req.json().catch(() => ({})) as { yaml?: string; spec?: unknown }
250
- let spec: unknown
251
- if (typeof body.yaml === "string") {
252
- try { spec = parseYaml(body.yaml) }
253
- catch (err) {
254
- return c.json({ ok: false, errors: [`YAML parse error: ${(err as Error).message}`] }, 400)
146
+ apiApp.post("/resources", async (c) => {
147
+ if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
148
+ const body = await c.req.json().catch(() => ({})) as {
149
+ name?: string
150
+ type?: string
151
+ fields?: Record<string, unknown>
255
152
  }
256
- } else if (body.spec) {
257
- spec = body.spec
258
- } else {
259
- return c.json({ ok: false, errors: ["Provide either `yaml` (string) or `spec` (object)"] }, 400)
260
- }
153
+ if (!body.name || typeof body.name !== "string") return c.json({ error: "name required" }, 400)
154
+ if (!body.type || typeof body.type !== "string") return c.json({ error: "type required" }, 400)
155
+ const fields = body.fields ?? {}
156
+ const r = upsertResource(options.adminDb, body.name, body.type, fields)
157
+ return c.json(r, 201)
158
+ })
261
159
 
262
- const result = validateSpec(spec)
263
- return c.json({
264
- ok: result.ok,
265
- errors: result.errors,
266
- warnings: result.warnings ?? [],
267
- edges: result.edges ?? [],
268
- }, result.ok ? 200 : 400)
269
- })
270
-
271
- /** Build the per-run baseCtx for runPipeline. Self-references via subRun
272
- * so sub-runs threaded from this top-level run carry the right context;
273
- * triggerSubRun rebinds the same closure to the sub-run's own runId. */
274
- function buildBaseCtx(runId: number): PlatformCtx {
275
- const baseCtx: PlatformCtx = {
276
- runId,
277
- db: dataDb(),
278
- cache,
279
- log: makeLogger(runId),
280
- resource: buildResourceCtx(runtimeDb()),
281
- runs: makeRunStore(runtimeDb()),
282
- subRun: undefined as unknown as PlatformCtx["subRun"],
283
- }
284
- baseCtx.subRun = (pid, payload) => triggerSubRun(pid, payload, baseCtx)
285
- return baseCtx
286
- }
160
+ apiApp.put("/resources/:name", async (c) => {
161
+ if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
162
+ const name = c.req.param("name")
163
+ const body = await c.req.json().catch(() => ({})) as {
164
+ type?: string
165
+ fields?: Record<string, unknown>
166
+ }
167
+ if (body.type !== undefined && body.fields !== undefined) {
168
+ const r = upsertResource(options.adminDb, name, body.type, body.fields)
169
+ return c.json(r)
170
+ }
171
+ if (body.fields !== undefined) {
172
+ const r = patchResource(options.adminDb, name, body.fields)
173
+ if (!r) return c.json({ error: "not found" }, 404)
174
+ return c.json(r)
175
+ }
176
+ return c.json({ error: "provide type+fields for replacement or fields for patch" }, 400)
177
+ })
287
178
 
288
- function persistOutcome(runId: number, outcome: RunOutcome): void {
289
- if (outcome.status === "done") {
290
- runtimeDb().prepare(
291
- `UPDATE runs SET status='done', result=?, steps=?, finished_at=datetime('now'),
292
- updated_at=datetime('now'), current_node=NULL WHERE id=?`,
293
- ).run(JSON.stringify(outcome.output), JSON.stringify(outcome.steps), runId)
294
- } else {
295
- persistNodeOutputs(runtimeDb(), runId, outcome.results)
296
- runtimeDb().prepare(
297
- `UPDATE runs SET status='suspended', steps=?, current_node=?, updated_at=datetime('now') WHERE id=?`,
298
- ).run(JSON.stringify(outcome.steps), outcome.suspendedAt.nodeId, runId)
299
- }
300
- }
179
+ apiApp.delete("/resources/:name", (c) => {
180
+ if (!options.adminDb) return c.json({ error: "resource admin unavailable for this runtime" }, 501)
181
+ const ok = deleteResource(options.adminDb, c.req.param("name"))
182
+ if (!ok) return c.json({ error: "not found" }, 404)
183
+ return new Response(null, { status: 204 })
184
+ })
185
+
186
+ apiApp.get("/pipelines", (c) => {
187
+ const list = getPipelines().map((p) => ({
188
+ id: p.id,
189
+ label: p.label ?? p.id,
190
+ description: p.description,
191
+ nodeCount: p.nodes.length,
192
+ }))
193
+ return c.json(list)
194
+ })
195
+
196
+ apiApp.get("/pipelines/:id", (c) => {
197
+ const spec = getPipeline(c.req.param("id"))
198
+ if (!spec) return c.json({ error: "not found" }, 404)
199
+ return c.json(publicSpec(spec))
200
+ })
201
+
202
+ apiApp.get("/pipelines/:id/source", (c) => {
203
+ const id = c.req.param("id")
204
+ if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
205
+ const path = join(pipelinesDir, `${id}.yaml`)
206
+ if (!existsSync(path)) return c.json({ error: "not found" }, 404)
207
+ return c.json({ id, yaml: readFileSync(path, "utf8") })
208
+ })
209
+
210
+ apiApp.post("/pipelines", async (c) => {
211
+ const body = await c.req.json().catch(() => ({})) as { yaml?: string }
212
+ if (!body.yaml) return c.json({ error: "yaml required" }, 400)
213
+
214
+ let parsed: unknown
215
+ try { parsed = parseYaml(body.yaml) }
216
+ catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
217
+
218
+ const id = (parsed as { id?: string })?.id
219
+ if (!id) return c.json({ error: "yaml must contain a top-level `id:` field" }, 400)
220
+ if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: `invalid id "${id}" — lowercase alphanum + hyphens, must start with alphanum` }, 400)
221
+
222
+ const path = join(pipelinesDir, `${id}.yaml`)
223
+ if (existsSync(path)) return c.json({ error: `pipeline "${id}" already exists` }, 409)
224
+
225
+ const result = validateSpec(parsed, { registry: runtime.registry })
226
+ if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
227
+
228
+ writeFileSync(path, body.yaml)
229
+ loadAll(pipelinesDir, { registry: runtime.registry })
230
+ return c.json({ id, ok: true }, 201)
231
+ })
232
+
233
+ apiApp.put("/pipelines/:id", async (c) => {
234
+ const id = c.req.param("id")
235
+ if (!VALID_PIPELINE_ID.test(id)) return c.json({ error: "invalid id" }, 400)
236
+
237
+ const path = join(pipelinesDir, `${id}.yaml`)
238
+ if (!existsSync(path)) return c.json({ error: "not found" }, 404)
239
+
240
+ const body = await c.req.json().catch(() => ({})) as { yaml?: string }
241
+ if (!body.yaml) return c.json({ error: "yaml required" }, 400)
242
+
243
+ let parsed: unknown
244
+ try { parsed = parseYaml(body.yaml) }
245
+ catch (err) { return c.json({ ok: false, errors: [`YAML parse: ${(err as Error).message}`] }, 400) }
246
+
247
+ const yamlId = (parsed as { id?: string })?.id
248
+ if (yamlId && yamlId !== id) {
249
+ return c.json({ error: `id mismatch: url says "${id}", yaml says "${yamlId}". Renames are not supported via edit.` }, 400)
250
+ }
251
+
252
+ const result = validateSpec(parsed, { registry: runtime.registry })
253
+ if (!result.ok) return c.json({ ok: false, errors: result.errors }, 400)
301
254
 
302
- function outcomeResponse(runId: number, pipelineId: string, outcome: RunOutcome) {
303
- if (outcome.status === "done") {
304
- return { id: runId, pipeline: pipelineId, status: "done", result: outcome.output, steps: outcome.steps }
255
+ writeFileSync(path, body.yaml)
256
+ loadAll(pipelinesDir, { registry: runtime.registry })
257
+ return c.json({ id, ok: true })
258
+ })
259
+
260
+ apiApp.post("/pipelines/validate", async (c) => {
261
+ const body = await c.req.json().catch(() => ({})) as { yaml?: string; spec?: unknown }
262
+ let spec: unknown
263
+ if (typeof body.yaml === "string") {
264
+ try { spec = parseYaml(body.yaml) }
265
+ catch (err) {
266
+ return c.json({ ok: false, errors: [`YAML parse error: ${(err as Error).message}`] }, 400)
267
+ }
268
+ } else if (body.spec) {
269
+ spec = body.spec
270
+ } else {
271
+ return c.json({ ok: false, errors: ["Provide either `yaml` (string) or `spec` (object)"] }, 400)
272
+ }
273
+
274
+ const result = validateSpec(spec, { registry: runtime.registry })
275
+ return c.json({
276
+ ok: result.ok,
277
+ errors: result.errors,
278
+ warnings: result.warnings ?? [],
279
+ edges: result.edges ?? [],
280
+ }, result.ok ? 200 : 400)
281
+ })
282
+
283
+ function getRunBroker(runId: number): RunEventBroker {
284
+ let broker = runEvents.get(runId)
285
+ if (!broker) {
286
+ broker = createRunEventBroker()
287
+ runEvents.set(runId, broker)
288
+ }
289
+ return broker
305
290
  }
306
- const task = getTask(runtimeDb(), outcome.suspendedAt.taskId)!
307
- return {
308
- id: runId, pipeline: pipelineId, status: "suspended",
309
- suspendedAt: outcome.suspendedAt.nodeId,
310
- task: {
311
- id: task.id,
312
- kind: task.kind,
313
- prompt: task.prompt,
314
- schema: task.schema,
315
- assignee: task.assignee,
316
- responseToken: task.responseToken,
317
- },
318
- steps: outcome.steps,
291
+
292
+ async function buildBaseCtx(runId: number, emit?: (event: ToistEvent) => void | Promise<void>): Promise<RunCtx> {
293
+ return {
294
+ runId,
295
+ runtime,
296
+ log: makeLogger(runtime, runId),
297
+ resource: await runtime.resources.resolve(),
298
+ emit,
299
+ }
319
300
  }
320
- }
321
301
 
322
- apiApp.post("/pipelines/:id/run", async (c) => {
323
- const id = c.req.param("id")
324
- const spec = getPipeline(id)
325
- if (!spec) return c.json({ error: "not found" }, 404)
326
-
327
- const payload = await c.req.json().catch(() => ({}))
328
- const run = runtimeDb().prepare(
329
- "INSERT INTO runs (pipeline, status, payload, trigger, updated_at) VALUES (?, 'running', ?, 'api', datetime('now')) RETURNING id",
330
- ).get(id, JSON.stringify(payload)) as { id: number }
331
-
332
- try {
333
- const outcome = await runPipeline(spec, payload as Record<string, unknown>, buildBaseCtx(run.id))
334
- persistOutcome(run.id, outcome)
335
- return c.json(outcomeResponse(run.id, id, outcome))
336
- } catch (err: unknown) {
337
- const message = err instanceof Error ? err.message : String(err)
338
- runtimeDb().prepare(
339
- "UPDATE runs SET status='error', error=?, finished_at=datetime('now'), updated_at=datetime('now') WHERE id=?",
340
- ).run(message, run.id)
341
- return c.json({ id: run.id, pipeline: id, status: "error", error: message }, 500)
302
+ async function persistOutcome(runId: number, outcome: RunOutcome): Promise<void> {
303
+ if (outcome.status === "done") {
304
+ await runtime.runs.markDone(runId, outcome.output, outcome.steps)
305
+ } else {
306
+ await runtime.outputs.putMany(runId, outcome.results)
307
+ await runtime.runs.markSuspended(runId, outcome.suspendedAt.nodeId, outcome.steps)
308
+ }
342
309
  }
343
- })
344
-
345
- // ─── HITL tasks queue + resume ─────────────────────────────────────────────
346
-
347
- apiApp.get("/tasks", (c) => {
348
- const status = c.req.query("status") ?? "open"
349
- const assignee = c.req.query("assignee")
350
- const runId = c.req.query("run_id")
351
- const limit = Number(c.req.query("limit") ?? 200)
352
-
353
- const where: string[] = []
354
- const args: Array<string | number> = []
355
- if (status !== "all") { where.push("t.status = ?"); args.push(status) }
356
- if (assignee) { where.push("t.assignee = ?"); args.push(assignee) }
357
- if (runId) { where.push("t.run_id = ?"); args.push(Number(runId)) }
358
-
359
- const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""
360
- args.push(limit)
361
- const sql =
362
- `SELECT t.id, t.run_id, t.node_id, t.kind, t.prompt, t.assignee, t.status,
363
- t.created_at, t.responded_at, r.pipeline AS pipeline
364
- FROM tasks t LEFT JOIN runs r ON t.run_id = r.id
365
- ${whereSql}
366
- ORDER BY t.id DESC LIMIT ?`
367
- return c.json(runtimeDb().prepare(sql).all(...args))
368
- })
369
-
370
- apiApp.get("/tasks/:id", (c) => {
371
- const task = getTask(runtimeDb(), Number(c.req.param("id")))
372
- if (!task) return c.json({ error: "not found" }, 404)
373
- return c.json(task)
374
- })
375
-
376
- apiApp.post("/runs/:runId/tasks/:taskId/respond", async (c) => {
377
- const runId = Number(c.req.param("runId"))
378
- const taskId = Number(c.req.param("taskId"))
379
- const body = await c.req.json().catch(() => ({})) as {
380
- token?: string
381
- response?: unknown
382
- respondedBy?: string
310
+
311
+ async function outcomeResponse(runId: number, pipelineId: string, outcome: RunOutcome) {
312
+ if (outcome.status === "done") {
313
+ return { id: runId, pipeline: pipelineId, status: "done", result: outcome.output, steps: outcome.steps }
314
+ }
315
+ const task = await runtime.tasks.get(outcome.suspendedAt.taskId)
316
+ if (!task) throw new Error(`task ${outcome.suspendedAt.taskId} not found`)
317
+ return {
318
+ id: runId,
319
+ pipeline: pipelineId,
320
+ status: "suspended",
321
+ suspendedAt: outcome.suspendedAt.nodeId,
322
+ task: {
323
+ id: task.id,
324
+ kind: task.kind,
325
+ prompt: task.prompt,
326
+ schema: task.schema,
327
+ assignee: task.assignee,
328
+ responseToken: task.responseToken,
329
+ },
330
+ steps: outcome.steps,
331
+ }
383
332
  }
384
- if (!body.token) return c.json({ error: "missing token" }, 400)
385
333
 
386
- // Read the task BEFORE answering so we know its kind (drives resume semantics).
387
- const taskBefore = getTask(runtimeDb(), taskId)
388
- if (!taskBefore) return c.json({ error: "task not found" }, 404)
334
+ async function executeRun(
335
+ spec: PipelineSpec,
336
+ pipelineId: string,
337
+ payload: Record<string, unknown>,
338
+ runId: number,
339
+ options: { rememberAdHoc?: boolean; resumeOutputs?: Record<string, unknown> } = {},
340
+ ) {
341
+ if (options.rememberAdHoc || adHocRuns.has(runId)) adHocRuns.set(runId, { spec, payload })
342
+
343
+ const broker = getRunBroker(runId)
344
+ const emit = async (event: ToistEvent) => { await broker.emit(event) }
345
+ await emit({ type: "run.started", runId, pipelineId })
346
+
347
+ try {
348
+ const outcome = await runPipeline(spec, payload, await buildBaseCtx(runId, emit), {
349
+ resumeOutputs: options.resumeOutputs,
350
+ })
351
+ await persistOutcome(runId, outcome)
352
+
353
+ if (outcome.status === "done") {
354
+ adHocRuns.delete(runId)
355
+ await emit({ type: "run.completed", runId, output: outcome.output })
356
+ } else {
357
+ await emit({
358
+ type: "run.suspended",
359
+ runId,
360
+ nodeId: outcome.suspendedAt.nodeId,
361
+ taskId: outcome.suspendedAt.taskId,
362
+ taskKind: outcome.suspendedAt.cause.kind,
363
+ })
364
+ }
389
365
 
390
- try {
391
- answerTask(runtimeDb(), taskId, body.token, body.response, body.respondedBy ?? null)
392
- } catch (err) {
393
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
366
+ return await outcomeResponse(runId, pipelineId, outcome)
367
+ } catch (err: unknown) {
368
+ adHocRuns.delete(runId)
369
+ const message = err instanceof Error ? err.message : String(err)
370
+ await runtime.runs.markError(runId, message)
371
+ await emit({ type: "run.error", runId, error: message })
372
+ return { id: runId, pipeline: pipelineId, status: "error", error: message }
373
+ } finally {
374
+ broker.finish()
375
+ }
394
376
  }
395
377
 
396
- const runRow = runtimeDb().prepare("SELECT id, pipeline, payload FROM runs WHERE id = ?").get(runId) as
397
- { id: number; pipeline: string; payload: string | null } | undefined
398
- if (!runRow) return c.json({ error: "run not found" }, 404)
378
+ apiApp.post("/pipelines/:id/run", async (c) => {
379
+ const id = c.req.param("id")
380
+ const spec = getPipeline(id)
381
+ if (!spec) return c.json({ error: "not found" }, 404)
382
+
383
+ const payload = await c.req.json().catch(() => ({})) as Record<string, unknown>
384
+ const runId = await runtime.runs.create({ pipeline: id, payload, trigger: "api" })
385
+ const response = await executeRun(spec, id, payload, runId)
386
+ return c.json(response, response.status === "error" ? 500 : 200)
387
+ })
388
+
389
+ apiApp.post("/runs", async (c) => {
390
+ const body = await c.req.json().catch(() => ({})) as {
391
+ spec?: unknown
392
+ payload?: Record<string, unknown>
393
+ }
394
+ if (!body.spec) return c.json({ error: "spec required" }, 400)
395
+
396
+ const result = validateSpec(body.spec, { registry: runtime.registry })
397
+ if (!result.ok || !result.parsed) {
398
+ return c.json({ ok: false, errors: result.errors, warnings: result.warnings ?? [] }, 400)
399
+ }
399
400
 
400
- const spec = getPipeline(runRow.pipeline)
401
- if (!spec) return c.json({ error: `pipeline "${runRow.pipeline}" no longer loaded` }, 500)
401
+ const payload = body.payload ?? {}
402
+ const runId = await runtime.runs.create({ pipeline: result.parsed.id, payload, trigger: "api" })
403
+ void executeRun(result.parsed, result.parsed.id, payload, runId, { rememberAdHoc: true })
404
+ return c.json({ id: runId, pipeline: result.parsed.id, status: "running" }, 202)
405
+ })
402
406
 
403
- const payload = runRow.payload ? JSON.parse(runRow.payload) : {}
407
+ apiApp.get("/tasks", async (c) => {
408
+ const status = c.req.query("status") ?? "open"
409
+ const assignee = c.req.query("assignee")
410
+ const runId = c.req.query("run_id")
411
+ const limit = Number(c.req.query("limit") ?? 200)
412
+ return c.json(await runtime.tasks.list({
413
+ status,
414
+ assignee,
415
+ runId: runId ? Number(runId) : undefined,
416
+ limit,
417
+ }))
418
+ })
404
419
 
405
- // error_review tasks have a different resume contract: the response carries
406
- // an `action` (retry | skip | abort), not free-form user input. Translate
407
- // before re-entering the dispatcher.
408
- if (taskBefore.kind === "error_review") {
409
- const action = (body.response as { action?: string } | null)?.action
410
- const value = (body.response as { value?: unknown } | null)?.value
420
+ apiApp.get("/tasks/:id", async (c) => {
421
+ const task = await runtime.tasks.get(Number(c.req.param("id")))
422
+ if (!task) return c.json({ error: "not found" }, 404)
423
+ return c.json(task)
424
+ })
411
425
 
412
- if (action === "abort") {
413
- runtimeDb().prepare(
414
- "UPDATE runs SET status='error', error=?, finished_at=datetime('now'), updated_at=datetime('now') WHERE id=?",
415
- ).run(`aborted via error_review task ${taskId}`, runId)
416
- return c.json({ id: runId, pipeline: runRow.pipeline, status: "error", error: "aborted by reviewer" })
426
+ apiApp.post("/runs/:runId/tasks/:taskId/respond", async (c) => {
427
+ const runId = Number(c.req.param("runId"))
428
+ const taskId = Number(c.req.param("taskId"))
429
+ const body = await c.req.json().catch(() => ({})) as {
430
+ token?: string
431
+ response?: unknown
432
+ respondedBy?: string
417
433
  }
434
+ if (!body.token) return c.json({ error: "missing token" }, 400)
418
435
 
419
- if (action === "skip") {
420
- // Insert the supplied value as the failed node's output. The dispatcher
421
- // resume path will treat the node as already-done.
422
- persistNodeOutputs(runtimeDb(), runId, { [taskBefore.nodeId]: value ?? null })
423
- } else if (action !== "retry") {
424
- return c.json({
425
- error: `error_review response must include action: "retry" | "skip" | "abort" (got ${JSON.stringify(action)})`,
426
- }, 400)
436
+ const taskBefore = await runtime.tasks.get(taskId)
437
+ if (!taskBefore) return c.json({ error: "task not found" }, 404)
438
+
439
+ try {
440
+ await runtime.tasks.answer(taskId, body.token, body.response, body.respondedBy ?? null)
441
+ } catch (err) {
442
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
427
443
  }
428
- // retry falls through — the failed node has no node_outputs entry, so
429
- // re-entering the dispatcher will execute it again.
430
- }
431
444
 
432
- const resumeOutputs = loadNodeOutputs(runtimeDb(), runId)
433
-
434
- runtimeDb().prepare(
435
- "UPDATE runs SET status='running', trigger='resumed', current_node=NULL, updated_at=datetime('now') WHERE id=?",
436
- ).run(runId)
437
-
438
- try {
439
- const outcome = await runPipeline(spec, payload, buildBaseCtx(runId), { resumeOutputs })
440
- persistOutcome(runId, outcome)
441
- return c.json(outcomeResponse(runId, runRow.pipeline, outcome))
442
- } catch (err: unknown) {
443
- const message = err instanceof Error ? err.message : String(err)
444
- runtimeDb().prepare(
445
- "UPDATE runs SET status='error', error=?, finished_at=datetime('now'), updated_at=datetime('now') WHERE id=?",
446
- ).run(message, runId)
447
- return c.json({ id: runId, pipeline: runRow.pipeline, status: "error", error: message }, 500)
448
- }
449
- })
450
-
451
- // ─── runs (runtime ledger) ───────────────────────────────────────────────────
452
- apiApp.get("/runs", (c) => {
453
- const limit = Number(c.req.query("limit") ?? 50)
454
- const pipelineFilter = c.req.query("pipeline")
455
- const rows = pipelineFilter
456
- ? runtimeDb().prepare("SELECT * FROM runs WHERE pipeline = ? ORDER BY id DESC LIMIT ?").all(pipelineFilter, limit)
457
- : runtimeDb().prepare("SELECT * FROM runs ORDER BY id DESC LIMIT ?").all(limit)
458
- return c.json(rows)
459
- })
460
-
461
- apiApp.get("/runs/:id", (c) => {
462
- const row = runtimeDb().prepare("SELECT * FROM runs WHERE id=?").get(Number(c.req.param("id")))
463
- if (!row) return c.json({ error: "not found" }, 404)
464
- return c.json(row)
465
- })
466
-
467
- apiApp.get("/runs/:id/nodes", (c) => {
468
- const runId = Number(c.req.param("id"))
469
- const rows = runtimeDb().prepare(
470
- "SELECT node_id, output_json, started_at, finished_at FROM node_outputs WHERE run_id = ? ORDER BY started_at ASC",
471
- ).all(runId) as Array<{ node_id: string; output_json: string | null; started_at: string; finished_at: string }>
472
- return c.json(rows.map((r) => ({
473
- nodeId: r.node_id,
474
- output: r.output_json != null ? JSON.parse(r.output_json) as unknown : null,
475
- startedAt: r.started_at,
476
- finishedAt: r.finished_at,
477
- })))
478
- })
479
-
480
- apiApp.get("/runs/:id/logs", (c) => {
481
- const runId = Number(c.req.param("id"))
482
- const rows = runtimeDb().prepare(
483
- "SELECT id, ts, level, msg FROM logs WHERE run_id = ? ORDER BY id ASC",
484
- ).all(runId)
485
- return c.json(rows)
486
- })
487
-
488
- // ─── lifecycle ───────────────────────────────────────────────────────────────
489
- const cachePruneTimer = setInterval(() => cache.prune(), 5 * 60 * 1000)
490
- cachePruneTimer.unref?.()
491
-
492
- const PORT = Number(process.env.PORT ?? 2132)
493
-
494
- // Bun's auto-launch path: only emit the port-log when index.ts is the entry
495
- // (i.e. legacy dev mode `bun src/index.ts`). When startRunner.ts imports this
496
- // module to extract the Hono app, it manages its own Bun.serve() and logs
497
- // against options.port, so this log would be wrong.
498
- if (import.meta.main) {
499
- console.log(`[runner] http://localhost:${PORT}`)
500
- }
445
+ const runRow = await runtime.runs.get(runId)
446
+ if (!runRow) return c.json({ error: "run not found" }, 404)
501
447
 
502
- // Outer app: owns CORS, the /api mount, and the bundled UI static serving
503
- // (per instance-spec.md §7 lifecycle step 9). Hosts can opt out via
504
- // startRunner({ disableUi: true }) useful when fronting with a separate UI
505
- // or running headless.
506
- const app = new Hono()
507
- const corsOrigins = getCorsOrigins()
508
- app.use("*", corsOrigins !== null ? cors({ origin: corsOrigins }) : cors())
509
- app.route("/api", apiApp)
510
-
511
- if (!isUiDisabled()) {
512
- const { distDir } = await import("@toist/ui")
513
- const indexPath = join(distDir, "index.html")
514
- if (existsSync(indexPath)) {
515
- // SPA serving: any GET maps to a file under dist/; non-asset paths fall
516
- // back to index.html so the client-side router takes over.
517
- //
518
- // Content-Type is set explicitly from BunFile.type. `new Response(file)`
519
- // alone does not always carry the MIME header through to the wire — at
520
- // least in some host environments (observed: published @toist/ui in
521
- // node_modules served from a host's runner) the header arrives empty,
522
- // which trips the browser's strict MIME check on JS module scripts.
523
- // Setting headers explicitly is correct and safe regardless of host.
524
- const respond = (file: ReturnType<typeof Bun.file>) =>
525
- new Response(file, {
526
- headers: { "Content-Type": file.type || "application/octet-stream" },
527
- })
528
- app.get("/*", async (c) => {
529
- const url = new URL(c.req.url).pathname
530
- const tryPath = url === "/" ? "/index.html" : url
531
- const file = Bun.file(join(distDir, tryPath))
532
- if (await file.exists()) return respond(file)
533
- // SPA fallback for unknown non-asset routes (no extension)
534
- if (!tryPath.includes(".")) {
535
- return respond(Bun.file(indexPath))
448
+ const adHoc = adHocRuns.get(runId)
449
+ const spec = adHoc?.spec ?? getPipeline(runRow.pipeline)
450
+ if (!spec) return c.json({ error: `pipeline "${runRow.pipeline}" no longer loaded` }, 500)
451
+
452
+ const payload = adHoc?.payload ?? (runRow.payload ? JSON.parse(runRow.payload) : {})
453
+
454
+ if (taskBefore.kind === "error_review") {
455
+ const action = (body.response as { action?: string } | null)?.action
456
+ const value = (body.response as { value?: unknown } | null)?.value
457
+
458
+ if (action === "abort") {
459
+ await runtime.runs.markError(runId, `aborted via error_review task ${taskId}`)
460
+ return c.json({ id: runId, pipeline: runRow.pipeline, status: "error", error: "aborted by reviewer" })
461
+ }
462
+
463
+ if (action === "skip") {
464
+ await runtime.outputs.putMany(runId, { [taskBefore.nodeId]: value ?? null })
465
+ } else if (action !== "retry") {
466
+ return c.json({
467
+ error: `error_review response must include action: "retry" | "skip" | "abort" (got ${JSON.stringify(action)})`,
468
+ }, 400)
536
469
  }
537
- return c.notFound()
470
+ }
471
+
472
+ const resumeOutputs = await runtime.outputs.list(runId)
473
+ await runtime.runs.markRunning(runId, { trigger: "resumed", clearCurrentNode: true })
474
+
475
+ const response = await executeRun(spec, runRow.pipeline, payload, runId, {
476
+ rememberAdHoc: !!adHoc,
477
+ resumeOutputs,
538
478
  })
539
- } else {
540
- console.warn(
541
- `[runner] UI dist not found at ${distDir}; skipping static mount. ` +
542
- `Run \`bun run build\` in @toist/ui to populate it.`,
543
- )
544
- }
545
- }
479
+ return c.json(response, response.status === "error" ? 500 : 200)
480
+ })
481
+
482
+ apiApp.get("/runs", async (c) => {
483
+ const limit = Number(c.req.query("limit") ?? 50)
484
+ const pipeline = c.req.query("pipeline")
485
+ return c.json(await runtime.runs.list({ pipeline, limit }))
486
+ })
487
+
488
+ apiApp.get("/runs/:id", async (c) => {
489
+ const row = await runtime.runs.get(Number(c.req.param("id")))
490
+ if (!row) return c.json({ error: "not found" }, 404)
491
+ return c.json(row)
492
+ })
493
+
494
+ apiApp.get("/runs/:id/events", async (c) => {
495
+ const runId = Number(c.req.param("id"))
496
+ const broker = runEvents.get(runId)
497
+ if (!broker) return c.json({ error: "event stream not found" }, 404)
498
+
499
+ const encoder = new TextEncoder()
500
+ const stream = new ReadableStream({
501
+ async start(controller) {
502
+ try {
503
+ for await (const event of broker.subscribe()) {
504
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
505
+ }
506
+ } finally {
507
+ controller.close()
508
+ }
509
+ },
510
+ })
511
+
512
+ return new Response(stream, {
513
+ headers: {
514
+ "Content-Type": "text/event-stream",
515
+ "Cache-Control": "no-cache",
516
+ Connection: "keep-alive",
517
+ },
518
+ })
519
+ })
546
520
 
547
- // Exported `fetch` lets startRunner.ts (and any future caller) bind the Hono
548
- // app to its own Bun.serve options without going through `default`.
549
- export const fetch = app.fetch
521
+ apiApp.get("/runs/:id/nodes", async (c) => {
522
+ const runId = Number(c.req.param("id"))
523
+ return c.json(await runtime.outputs.listOrdered(runId))
524
+ })
525
+
526
+ apiApp.get("/runs/:id/logs", async (c) => {
527
+ const runId = Number(c.req.param("id"))
528
+ return c.json(await runtime.logs.list(runId))
529
+ })
530
+
531
+ const app = new Hono()
532
+ app.use("*", options.corsOrigins !== undefined ? cors({ origin: options.corsOrigins }) : cors())
533
+ app.route("/api", apiApp)
534
+
535
+ if (!options.disableUi) {
536
+ const { distDir } = await import("@toist/ui")
537
+ const indexPath = join(distDir, "index.html")
538
+ if (!existsSync(indexPath)) {
539
+ console.warn(
540
+ `[runner] UI dist not found at ${distDir}; skipping static mount. ` +
541
+ "Run `bun run build` in @toist/ui to populate it.",
542
+ )
543
+ } else {
544
+ const respond = (file: ReturnType<typeof Bun.file>) =>
545
+ new Response(file, {
546
+ headers: { "Content-Type": file.type || "application/octet-stream" },
547
+ })
548
+
549
+ app.get("/*", async (c) => {
550
+ const url = new URL(c.req.url).pathname
551
+ const tryPath = url === "/" ? "/index.html" : url
552
+ const file = Bun.file(join(distDir, tryPath))
553
+ if (await file.exists()) return respond(file)
554
+ if (!tryPath.includes(".")) return respond(Bun.file(indexPath))
555
+ return c.notFound()
556
+ })
557
+ }
558
+ }
550
559
 
551
- export default { port: PORT, fetch: app.fetch }
560
+ return app
561
+ }