@fabrika/builder 0.2.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.
Files changed (112) hide show
  1. package/BUILD-CONTRACT.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +100 -0
  4. package/cli/src/bin.ts +66 -0
  5. package/cli/src/build-status.ts +109 -0
  6. package/cli/src/catalog.ts +42 -0
  7. package/cli/src/commands/advance.ts +73 -0
  8. package/cli/src/commands/build.ts +215 -0
  9. package/cli/src/commands/ensure-daemon.ts +25 -0
  10. package/cli/src/commands/gate.ts +165 -0
  11. package/cli/src/commands/induce.ts +61 -0
  12. package/cli/src/commands/init.ts +91 -0
  13. package/cli/src/commands/next.ts +31 -0
  14. package/cli/src/config.ts +47 -0
  15. package/cli/src/context.ts +28 -0
  16. package/cli/src/daemon-client.ts +280 -0
  17. package/cli/src/daemon-lifecycle.ts +171 -0
  18. package/cli/src/derive.ts +109 -0
  19. package/cli/src/index.ts +38 -0
  20. package/cli/src/induce.ts +151 -0
  21. package/cli/src/inject-skills.ts +313 -0
  22. package/cli/src/issues.ts +75 -0
  23. package/cli/src/layout.ts +124 -0
  24. package/cli/src/packet.ts +108 -0
  25. package/cli/src/phase-state.ts +19 -0
  26. package/cli/src/phases.ts +131 -0
  27. package/cli/src/skeletons.ts +104 -0
  28. package/daemon/src/bin.ts +70 -0
  29. package/daemon/src/build-stream.ts +38 -0
  30. package/daemon/src/handlers.ts +472 -0
  31. package/daemon/src/index.ts +123 -0
  32. package/daemon/src/paths.ts +30 -0
  33. package/daemon/src/phases.ts +221 -0
  34. package/daemon/src/static.ts +72 -0
  35. package/decisions/README.md +133 -0
  36. package/decisions/patterns/app-deploy.md +55 -0
  37. package/decisions/patterns/auth-password-kv.md +45 -0
  38. package/decisions/patterns/count-store.md +44 -0
  39. package/decisions/patterns/di-container.md +47 -0
  40. package/decisions/patterns/feature-architecture.md +48 -0
  41. package/decisions/patterns/lib-publish.md +46 -0
  42. package/decisions/patterns/monorepo.md +47 -0
  43. package/decisions/patterns/named-seeds.md +45 -0
  44. package/decisions/patterns/observability.md +45 -0
  45. package/decisions/patterns/single-worker-spa.md +74 -0
  46. package/decisions/patterns/structured-logging.md +49 -0
  47. package/decisions/patterns/typed-worker-rpc.md +87 -0
  48. package/decisions/patterns/vozka-app-deploy.md +74 -0
  49. package/decisions/patterns/write-split.md +47 -0
  50. package/decisions/technologies/analytics-engine.md +44 -0
  51. package/decisions/technologies/bindx.md +55 -0
  52. package/decisions/technologies/bun.md +51 -0
  53. package/decisions/technologies/buzola.md +80 -0
  54. package/decisions/technologies/cloudflare-access.md +50 -0
  55. package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
  56. package/decisions/technologies/cloudflare.md +50 -0
  57. package/decisions/technologies/contember.md +65 -0
  58. package/decisions/technologies/cron-triggers.md +46 -0
  59. package/decisions/technologies/d1.md +51 -0
  60. package/decisions/technologies/durable-objects.md +44 -0
  61. package/decisions/technologies/kv.md +42 -0
  62. package/decisions/technologies/lopata.md +55 -0
  63. package/decisions/technologies/nuasite.md +50 -0
  64. package/decisions/technologies/oblaka.md +48 -0
  65. package/decisions/technologies/opice.md +40 -0
  66. package/decisions/technologies/pletivo.md +53 -0
  67. package/decisions/technologies/postgres.md +47 -0
  68. package/decisions/technologies/preact.md +44 -0
  69. package/decisions/technologies/propustka.md +137 -0
  70. package/decisions/technologies/queue.md +55 -0
  71. package/decisions/technologies/r2.md +41 -0
  72. package/decisions/technologies/react-router.md +47 -0
  73. package/decisions/technologies/react.md +37 -0
  74. package/decisions/technologies/roj.md +51 -0
  75. package/decisions/technologies/sentry.md +49 -0
  76. package/decisions/technologies/tailwind.md +45 -0
  77. package/decisions/technologies/vite.md +47 -0
  78. package/decisions/technologies/vozka.md +122 -0
  79. package/decisions/technologies/workers-ai.md +59 -0
  80. package/decisions/technologies/wrangler.md +41 -0
  81. package/decisions/tooling/deptective.md +45 -0
  82. package/decisions/tooling/edvabe.md +45 -0
  83. package/index.ts +2 -0
  84. package/package.json +51 -0
  85. package/skill/SKILL.md +76 -0
  86. package/skill/agents/build-orchestrator.md +170 -0
  87. package/skill/agents/flows-author.md +65 -0
  88. package/skill/agents/implementer.md +37 -0
  89. package/skill/agents/openai.yaml +4 -0
  90. package/skill/agents/opice-skeleton-author.md +68 -0
  91. package/skill/gates/brief.jsx +20 -0
  92. package/skill/gates/build.jsx +20 -0
  93. package/skill/gates/design.jsx +22 -0
  94. package/skill/gates/flows.jsx +14 -0
  95. package/skill/gates/review-grid.jsx +32 -0
  96. package/skill/gates/tech.jsx +17 -0
  97. package/skill/gates/vision.jsx +27 -0
  98. package/stacks/README.md +44 -0
  99. package/stacks/cf-fat.md +64 -0
  100. package/stacks/cf-internal-app.md +125 -0
  101. package/stacks/cf-lightweight.md +54 -0
  102. package/stacks/contember-app.md +53 -0
  103. package/stacks/content-site.md +45 -0
  104. package/ui/dist/assets/annotations-runtime.js +1 -0
  105. package/ui/dist/assets/client.css +2 -0
  106. package/ui/dist/assets/client.fhjppkvr.js +214 -0
  107. package/ui/dist/assets/components.js +63 -0
  108. package/ui/dist/assets/preact-compat.js +1 -0
  109. package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
  110. package/ui/dist/assets/react-jsx-runtime.js +3 -0
  111. package/ui/dist/assets/runtime.js +1 -0
  112. package/ui/dist/index.html +36 -0
@@ -0,0 +1,472 @@
1
+ import { type CanvasScope, compileJsx, type SessionManager, watchSession } from '@fabrika/canvas-kernel/server'
2
+ import type { Route } from '@fabrika/daemon-kit'
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
4
+ import { basename, dirname, extname, join, resolve, sep } from 'path'
5
+ import type { BuildEvent, BuildStream } from './build-stream'
6
+ import { COMPILE_TEMP_DIR } from './paths'
7
+ import { derivePhaseState, type GateOracle } from './phases'
8
+
9
+ function jsonResponse(data: unknown, status = 200): Response {
10
+ return new Response(JSON.stringify(data), {
11
+ status,
12
+ headers: { 'Content-Type': 'application/json' },
13
+ })
14
+ }
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
18
+ }
19
+
20
+ function errorMessage(error: unknown): string {
21
+ return error instanceof Error ? error.message : String(error)
22
+ }
23
+
24
+ function routeParam(match: URLPatternResult, name: string): string {
25
+ const value = match.pathname.groups[name]
26
+ if (value === undefined) throw new Error(`Missing route parameter: ${name}`)
27
+ return value
28
+ }
29
+
30
+ function optionalString(value: unknown): string | undefined {
31
+ return typeof value === 'string' ? value : undefined
32
+ }
33
+
34
+ function isBuildStatus(value: unknown): value is BuildEvent['status'] {
35
+ return value === 'queued' || value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked'
36
+ || value === 'info'
37
+ }
38
+
39
+ function parseBuildEvent(value: unknown): BuildEvent | null {
40
+ if (!isRecord(value) || !isBuildStatus(value['status'])) return null
41
+ const issue = optionalString(value['issue'])
42
+ const message = optionalString(value['message'])
43
+ const at = optionalString(value['at'])
44
+ const data = isRecord(value['data']) ? value['data'] : undefined
45
+ return {
46
+ status: value['status'],
47
+ ...(issue === undefined ? {} : { issue }),
48
+ ...(message === undefined ? {} : { message }),
49
+ ...(at === undefined ? {} : { at }),
50
+ ...(data === undefined ? {} : { data }),
51
+ }
52
+ }
53
+
54
+ export function corsHeaders(port: number): Record<string, string> {
55
+ return {
56
+ 'Access-Control-Allow-Origin': `http://localhost:${port}`,
57
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
58
+ 'Access-Control-Allow-Headers': 'Content-Type',
59
+ }
60
+ }
61
+
62
+ export interface HandlerContext {
63
+ sessionManager: SessionManager
64
+ broadcastPlanUpdate: (id: string) => void
65
+ broadcastRevisionUpdate: (id: string) => void
66
+ buildStream: BuildStream
67
+ sessionsDir: string
68
+ port: number
69
+ version: string
70
+ }
71
+
72
+ function gateOracleFor(sm: SessionManager): GateOracle {
73
+ return {
74
+ getLatestUnconsumedFeedback: (id) => sm.getLatestUnconsumedFeedback(id),
75
+ hasGateRevision: (id, gateName) => {
76
+ const session = sm.get(id)
77
+ if (!session) return false
78
+ for (let i = session.revisions.length - 1; i >= 0; i--) {
79
+ const r = session.revisions[i]!
80
+ if (r.label === gateName) return r.hasFeedback
81
+ }
82
+ return false
83
+ },
84
+ }
85
+ }
86
+
87
+ interface CanvasSelection {
88
+ files: Map<string, string>
89
+ scope: CanvasScope
90
+ }
91
+
92
+ function resolveCanvasSelection(body: Record<string, unknown>): CanvasSelection {
93
+ const view = typeof body['view'] === 'string' ? body['view'] : null
94
+ const directory = typeof body['directory'] === 'string' ? body['directory'] : null
95
+ if (!!view === !!directory) {
96
+ throw new Error('Provide exactly one of view or directory')
97
+ }
98
+
99
+ if (view) {
100
+ if (!existsSync(view) || !statSync(view).isFile()) {
101
+ throw new Error(`Canvas view not found: ${view}`)
102
+ }
103
+ if (extname(view).toLowerCase() !== '.jsx') {
104
+ throw new Error(`Canvas view must be a .jsx file: ${view}`)
105
+ }
106
+ const filename = basename(view)
107
+ return {
108
+ files: new Map([[filename, readFileSync(view, 'utf-8')]]),
109
+ scope: { kind: 'view', filename },
110
+ }
111
+ }
112
+
113
+ if (directory === null) throw new Error('Canvas directory is missing')
114
+ if (!existsSync(directory) || !statSync(directory).isDirectory()) {
115
+ throw new Error(`Canvas directory not found: ${directory}`)
116
+ }
117
+ const filenames = readdirSync(directory).filter((f) => f.endsWith('.jsx')).sort()
118
+ if (filenames.length === 0) throw new Error('No .jsx files found in directory')
119
+ const files = new Map<string, string>()
120
+ for (const filename of filenames) {
121
+ files.set(filename, readFileSync(join(directory, filename), 'utf-8'))
122
+ }
123
+ return { files, scope: { kind: 'directory' } }
124
+ }
125
+
126
+ export function createHandlers(ctx: HandlerContext): Route[] {
127
+ const { sessionManager, broadcastPlanUpdate, broadcastRevisionUpdate, buildStream } = ctx
128
+
129
+ function handleHealth(): Response {
130
+ return jsonResponse({
131
+ ok: true,
132
+ version: ctx.version,
133
+ pid: process.pid,
134
+ dataDir: dirname(ctx.sessionsDir),
135
+ sessionsDir: ctx.sessionsDir,
136
+ sessions: sessionManager.list().map((s) => s.id),
137
+ })
138
+ }
139
+
140
+ function handlePhase(_req: Request, url: URL): Response {
141
+ const root = url.searchParams.get('root')
142
+ if (!root) return jsonResponse({ error: 'Missing ?root=<fabrikaDir>' }, 400)
143
+ const sessionId = url.searchParams.get('session') ?? ''
144
+ try {
145
+ const state = derivePhaseState(root, sessionId, gateOracleFor(sessionManager))
146
+ return jsonResponse(state)
147
+ } catch (error: unknown) {
148
+ return jsonResponse({ error: errorMessage(error) }, 500)
149
+ }
150
+ }
151
+
152
+ async function handlePlanPost(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
153
+ const sessionId = routeParam(match, 'id')
154
+ try {
155
+ const parsedBody: unknown = await req.json()
156
+ if (!isRecord(parsedBody)) return jsonResponse({ error: 'Expected a JSON object' }, 400)
157
+ const body = parsedBody
158
+ let selection: CanvasSelection
159
+ try {
160
+ selection = resolveCanvasSelection(body)
161
+ } catch (error) {
162
+ return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400)
163
+ }
164
+ const canvasFiles = selection.files
165
+
166
+ const unconsumed = sessionManager.getLatestUnconsumedFeedback(sessionId)
167
+ if (unconsumed) {
168
+ sessionManager.consumeFeedback(sessionId, unconsumed.revision)
169
+ return jsonResponse(
170
+ {
171
+ ok: false,
172
+ error: `Unconsumed feedback from revision ${unconsumed.revision}. Address the feedback before pushing a new canvas.`,
173
+ unconsumedFeedback: unconsumed.feedback,
174
+ unconsumedRevision: unconsumed.revision,
175
+ },
176
+ 409,
177
+ )
178
+ }
179
+
180
+ const projectRoot = optionalString(body['projectRoot']) ?? process.cwd()
181
+ const isNew = !sessionManager.get(sessionId)
182
+ const gate = optionalString(body['gate'])
183
+ const label = gate ?? optionalString(body['label'])
184
+ const response = optionalString(body['response'])
185
+
186
+ const compiled = new Map<string, string>()
187
+ const errors: Record<string, string> = {}
188
+ await Promise.all(
189
+ [...canvasFiles.entries()].map(async ([filename, jsx]) => {
190
+ const result = await compileJsx(jsx, { projectRoot, tempDir: COMPILE_TEMP_DIR })
191
+ if (result.ok) compiled.set(filename, result.js)
192
+ else errors[filename] = result.error
193
+ }),
194
+ )
195
+ if (Object.keys(errors).length > 0) {
196
+ return jsonResponse({ ok: false, error: 'Canvas compilation failed', errors }, 400)
197
+ }
198
+
199
+ const session = sessionManager.upsert(
200
+ sessionId,
201
+ canvasFiles,
202
+ projectRoot,
203
+ label,
204
+ response,
205
+ selection.scope,
206
+ )
207
+ for (const [filename, js] of compiled) {
208
+ sessionManager.saveCompiled(sessionId, filename, js, session.currentRevision)
209
+ }
210
+
211
+ broadcastPlanUpdate(sessionId)
212
+ watchSession(sessionId, sessionManager, broadcastPlanUpdate)
213
+
214
+ const browserUrl = `http://localhost:${ctx.port}/s/${sessionId}`
215
+ return jsonResponse({
216
+ ok: true,
217
+ browserUrl,
218
+ isNew,
219
+ revision: session.currentRevision,
220
+ sessionId,
221
+ ...(gate === undefined ? {} : { gate }),
222
+ canvasFiles: [...canvasFiles.keys()],
223
+ })
224
+ } catch (error: unknown) {
225
+ return jsonResponse({ error: errorMessage(error) }, 500)
226
+ }
227
+ }
228
+
229
+ async function handleCanvasJs(_req: Request, url: URL, match: URLPatternResult): Promise<Response> {
230
+ const sessionId = routeParam(match, 'id')
231
+ const jsFilename = routeParam(match, 'filename')
232
+ const jsxFilename = jsFilename.replace(/\.js$/, '.jsx')
233
+ const revParam = url.searchParams.get('rev')
234
+ const rev = revParam ? parseInt(revParam, 10) : undefined
235
+
236
+ let compiled = sessionManager.getCompiled(sessionId, jsxFilename, rev)
237
+
238
+ if (!compiled && rev) {
239
+ const session = sessionManager.get(sessionId)
240
+ if (session) {
241
+ const jsx = sessionManager.readRevisionJsx(sessionId, rev, jsxFilename)
242
+ if (jsx) {
243
+ const result = await compileJsx(jsx, { projectRoot: session.projectRoot, tempDir: COMPILE_TEMP_DIR })
244
+ if (result.ok) {
245
+ sessionManager.saveCompiled(sessionId, jsxFilename, result.js, rev)
246
+ compiled = result.js
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ if (!compiled) return jsonResponse({ error: 'No compiled canvas' }, 404)
253
+ return new Response(compiled, {
254
+ headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' },
255
+ })
256
+ }
257
+
258
+ function handleMeta(_req: Request, _url: URL, match: URLPatternResult): Response {
259
+ const sessionId = routeParam(match, 'id')
260
+ const session = sessionManager.get(sessionId)
261
+ if (!session) return jsonResponse({ error: 'Session not found' }, 404)
262
+ return jsonResponse({
263
+ projectRoot: session.projectRoot,
264
+ currentRevision: session.currentRevision,
265
+ revisions: session.revisions,
266
+ canvasFiles: session.canvasFiles,
267
+ createdAt: session.createdAt,
268
+ updatedAt: session.updatedAt,
269
+ })
270
+ }
271
+
272
+ function handleArtifact(_req: Request, url: URL, match: URLPatternResult): Response {
273
+ const sessionId = routeParam(match, 'id')
274
+ const session = sessionManager.get(sessionId)
275
+ if (!session) return jsonResponse({ error: 'Session not found' }, 404)
276
+
277
+ const rel = url.searchParams.get('path')
278
+ if (!rel) return jsonResponse({ error: 'Missing ?path=<relative path>' }, 400)
279
+
280
+ const root = resolve(session.projectRoot)
281
+ const target = resolve(root, rel)
282
+ if (target !== root && !target.startsWith(root + sep)) {
283
+ return jsonResponse({ error: 'Path escapes project root' }, 403)
284
+ }
285
+ try {
286
+ const st = statSync(target)
287
+ if (!st.isFile()) return jsonResponse({ error: 'Not a file' }, 404)
288
+ return jsonResponse({ path: rel, content: readFileSync(target, 'utf-8') })
289
+ } catch {
290
+ return jsonResponse({ error: 'Artifact not found' }, 404)
291
+ }
292
+ }
293
+
294
+ function handleSkeletons(_req: Request, _url: URL, match: URLPatternResult): Response {
295
+ const sessionId = routeParam(match, 'id')
296
+ const session = sessionManager.get(sessionId)
297
+ if (!session) return jsonResponse({ error: 'Session not found' }, 404)
298
+ const dir = join(session.projectRoot, 'tests', 'browser')
299
+ if (!existsSync(dir)) return jsonResponse({ skeletons: [] })
300
+
301
+ const matchBrace = (s: string, open: number): number => {
302
+ let depth = 0
303
+ for (let i = open; i < s.length; i++) {
304
+ if (s[i] === '{') depth++
305
+ else if (s[i] === '}' && --depth === 0) return i
306
+ }
307
+ return -1
308
+ }
309
+ const routePath = (u: string | undefined): string | undefined => {
310
+ if (!u) return undefined
311
+ let s = u.replace(/\$\{[^}]*\}/g, '').replace(/^https?:\/\/[^/]+/, '')
312
+ s = s.split('#')[0]!.split('?')[0]!
313
+ if (!s.startsWith('/')) s = '/' + s
314
+ return s.replace(/\/{2,}/g, '/') || '/'
315
+ }
316
+
317
+ const skeletons: unknown[] = []
318
+ for (const entry of readdirSync(dir).sort()) {
319
+ if (!/\.test\.ts$/.test(entry)) continue
320
+ const src = readFileSync(join(dir, entry), 'utf-8')
321
+ const re = /browserTest\s*\(/g
322
+ let mm: RegExpExecArray | null
323
+ while ((mm = re.exec(src))) {
324
+ const objStart = src.indexOf('{', mm.index)
325
+ if (objStart === -1) continue
326
+ const objEnd = matchBrace(src, objStart)
327
+ if (objEnd === -1) continue
328
+ const meta = src.slice(objStart, objEnd + 1)
329
+ const str = (k: string) => meta.match(new RegExp(`\\b${k}\\s*:\\s*[\`'"]([^\`'"]*)`))?.[1]
330
+ const arr = (k: string) => {
331
+ const a = meta.match(new RegExp(`\\b${k}\\s*:\\s*\\[([\\s\\S]*?)\\]`))?.[1] ?? ''
332
+ return [...a.matchAll(/[`'"]([^`'"]+)[`'"]/g)].map((x) => x[1])
333
+ }
334
+ const after = src.slice(objEnd + 1)
335
+ const nextBT = after.search(/\bbrowserTest\s*\(/)
336
+ const body = nextBT === -1 ? after : after.slice(0, nextBT)
337
+ const steps = (body.match(/\bstep(?:\.\w+)?\s*\(/g) ?? []).length
338
+ const invariants = (body.match(/\binvariant(?:\.\w+)?\s*\(/g) ?? []).length
339
+ const pending = (body.match(/\bstep\s*\(\s*[`'"][^`'"]*[`'"]\s*,\s*\{[^}]*\}\s*\)/g) ?? []).length
340
+ + (body.match(/\binvariant\.todo\s*\(/g) ?? []).length
341
+ skeletons.push({
342
+ file: entry,
343
+ name: str('name') ?? entry.replace(/\.test\.ts$/, ''),
344
+ url: routePath(str('url')),
345
+ feature: str('feature'),
346
+ seeds: arr('seeds'),
347
+ roles: arr('roles'),
348
+ steps,
349
+ invariants,
350
+ pending,
351
+ })
352
+ }
353
+ }
354
+ return jsonResponse({ skeletons })
355
+ }
356
+
357
+ function handleFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
358
+ const sessionId = routeParam(match, 'id')
359
+ const rev = parseInt(routeParam(match, 'rev'), 10)
360
+ const feedback = sessionManager.getFeedback(sessionId, rev)
361
+ if (feedback === null) return jsonResponse({ error: 'No feedback' }, 404)
362
+ return jsonResponse({ feedback })
363
+ }
364
+
365
+ function handleFeedbackConsume(_req: Request, _url: URL, match: URLPatternResult): Response {
366
+ const sessionId = routeParam(match, 'id')
367
+ const result = sessionManager.getLatestUnconsumedFeedback(sessionId)
368
+ if (!result) return jsonResponse({ found: false })
369
+ sessionManager.consumeFeedback(sessionId, result.revision)
370
+ broadcastRevisionUpdate(sessionId)
371
+ return jsonResponse({ found: true, revision: result.revision, feedback: result.feedback })
372
+ }
373
+
374
+ function handleResponsesGet(_req: Request, _url: URL, match: URLPatternResult): Response {
375
+ const sessionId = routeParam(match, 'id')
376
+ const rev = parseInt(routeParam(match, 'rev'), 10)
377
+ const responses = sessionManager.getResponses(sessionId, rev)
378
+ return jsonResponse({ responses })
379
+ }
380
+
381
+ async function handleResponsesPut(req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
382
+ const sessionId = routeParam(match, 'id')
383
+ const rev = parseInt(routeParam(match, 'rev'), 10)
384
+ if (!sessionManager.get(sessionId)) return jsonResponse({ error: 'Session not found' }, 404)
385
+ try {
386
+ const body = await req.json()
387
+ sessionManager.saveResponses(sessionId, rev, body)
388
+ return jsonResponse({ ok: true })
389
+ } catch (error: unknown) {
390
+ return jsonResponse({ error: errorMessage(error) }, 400)
391
+ }
392
+ }
393
+
394
+ function handleSessionReset(_req: Request, _url: URL, match: URLPatternResult): Response {
395
+ const sessionId = routeParam(match, 'id')
396
+ const existed = !!sessionManager.get(sessionId)
397
+ if (existed) {
398
+ sessionManager.remove(sessionId)
399
+ broadcastRevisionUpdate(sessionId)
400
+ }
401
+ return jsonResponse({ ok: true, existed })
402
+ }
403
+
404
+ function handleSessions(): Response {
405
+ return jsonResponse(
406
+ sessionManager.list().map((s) => ({
407
+ id: s.id,
408
+ projectRoot: s.projectRoot,
409
+ currentRevision: s.currentRevision,
410
+ updatedAt: s.updatedAt,
411
+ })),
412
+ )
413
+ }
414
+
415
+ async function handleBuildEvent(req: Request): Promise<Response> {
416
+ try {
417
+ const body: unknown = await req.json()
418
+ const event = parseBuildEvent(body)
419
+ if (event === null) return jsonResponse({ error: 'Invalid or missing status' }, 400)
420
+ const ev = buildStream.append(event)
421
+ return jsonResponse({ ok: true, event: ev })
422
+ } catch (error: unknown) {
423
+ return jsonResponse({ error: errorMessage(error) }, 500)
424
+ }
425
+ }
426
+
427
+ function handleBuildStream(): Response {
428
+ const encoder = new TextEncoder()
429
+ let unsubscribe: (() => void) | null = null
430
+ const stream = new ReadableStream({
431
+ start(controller) {
432
+ const send = (ev: BuildEvent) => {
433
+ try {
434
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`))
435
+ } catch {
436
+ }
437
+ }
438
+ for (const ev of buildStream.history()) send(ev)
439
+ controller.enqueue(encoder.encode(`: connected\n\n`))
440
+ unsubscribe = buildStream.subscribe(send)
441
+ },
442
+ cancel() {
443
+ unsubscribe?.()
444
+ },
445
+ })
446
+ return new Response(stream, {
447
+ headers: {
448
+ 'Content-Type': 'text/event-stream',
449
+ 'Cache-Control': 'no-cache, no-transform',
450
+ Connection: 'keep-alive',
451
+ },
452
+ })
453
+ }
454
+
455
+ return [
456
+ { method: 'GET', pattern: new URLPattern({ pathname: '/health' }), handler: handleHealth },
457
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/phase' }), handler: handlePhase },
458
+ { method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/plan' }), handler: handlePlanPost },
459
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/canvas/:filename' }), handler: handleCanvasJs },
460
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/meta' }), handler: handleMeta },
461
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/artifact' }), handler: handleArtifact },
462
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/skeletons' }), handler: handleSkeletons },
463
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/feedback' }), handler: handleFeedbackGet },
464
+ { method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/feedback/consume' }), handler: handleFeedbackConsume },
465
+ { method: 'POST', pattern: new URLPattern({ pathname: '/api/session/:id/reset' }), handler: handleSessionReset },
466
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/responses' }), handler: handleResponsesGet },
467
+ { method: 'PUT', pattern: new URLPattern({ pathname: '/api/session/:id/revision/:rev/responses' }), handler: handleResponsesPut },
468
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/sessions' }), handler: handleSessions },
469
+ { method: 'POST', pattern: new URLPattern({ pathname: '/api/build/event' }), handler: handleBuildEvent },
470
+ { method: 'GET', pattern: new URLPattern({ pathname: '/api/build/stream' }), handler: handleBuildStream },
471
+ ]
472
+ }
@@ -0,0 +1,123 @@
1
+ import { createWebSocketManager, SessionManager, unwatchAll, type WSData } from '@fabrika/canvas-kernel/server'
2
+ import { dispatch } from '@fabrika/daemon-kit'
3
+ import { resolve } from 'path'
4
+ import { BuildStream } from './build-stream'
5
+ import { corsHeaders, createHandlers } from './handlers'
6
+ import { SESSIONS_DIR } from './paths'
7
+ import { createStaticHandlers } from './static'
8
+
9
+ export type { BuildEvent } from './build-stream'
10
+ export { derivePhaseState, PHASES } from './phases'
11
+ export type { Phase, PhaseId, PhaseState } from './phases'
12
+
13
+ export interface GateRecord {
14
+ name: string
15
+ revision: number
16
+ hasFeedback: boolean
17
+ consumed: boolean
18
+ }
19
+
20
+ const DEFAULT_PORT = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
21
+ const VERSION = '0.0.0'
22
+
23
+ export interface StartDaemonOptions {
24
+ port?: number
25
+ sessionsDir?: string
26
+ }
27
+
28
+ export interface RunningDaemon {
29
+ server: import('bun').Server<WSData>
30
+ port: number
31
+ sessionManager: SessionManager
32
+ stop(): void
33
+ }
34
+
35
+ export function startDaemon(opts: StartDaemonOptions = {}): RunningDaemon {
36
+ const port = opts.port ?? DEFAULT_PORT
37
+
38
+ const sessionsDir = resolve(opts.sessionsDir ?? SESSIONS_DIR)
39
+ const sessionManager = new SessionManager(sessionsDir)
40
+ sessionManager.cleanupStale()
41
+ const wsManager = createWebSocketManager(sessionManager, { feedbackConsumption: 'external' })
42
+ const buildStream = new BuildStream()
43
+
44
+ const portRef = { value: port }
45
+
46
+ const handlerCtx = {
47
+ sessionManager,
48
+ broadcastPlanUpdate: wsManager.broadcastPlanUpdate,
49
+ broadcastRevisionUpdate: wsManager.broadcastRevisionUpdate,
50
+ buildStream,
51
+ sessionsDir,
52
+ get port() {
53
+ return portRef.value
54
+ },
55
+ version: VERSION,
56
+ }
57
+
58
+ const routes = [...createHandlers(handlerCtx), ...createStaticHandlers()]
59
+
60
+ const server = Bun.serve<WSData>({
61
+ port,
62
+ hostname: 'localhost',
63
+ fetch(req, server) {
64
+ const url = new URL(req.url)
65
+
66
+ if (req.method === 'OPTIONS') {
67
+ return new Response(null, { status: 204, headers: corsHeaders(portRef.value) })
68
+ }
69
+
70
+ const wsBrowser = url.pathname.match(/^\/ws\/session\/([^/]+)$/)
71
+ if (wsBrowser) {
72
+ const ok = server.upgrade(req, { data: { type: 'browser', sessionId: wsBrowser[1]! } })
73
+ if (!ok) return new Response('WebSocket upgrade failed', { status: 400 })
74
+ return
75
+ }
76
+
77
+ const wsWait = url.pathname.match(/^\/ws\/wait\/([^/]+)$/)
78
+ if (wsWait) {
79
+ const ok = server.upgrade(req, { data: { type: 'wait', sessionId: wsWait[1]! } })
80
+ if (!ok) return new Response('WebSocket upgrade failed', { status: 400 })
81
+ return
82
+ }
83
+
84
+ const response = dispatch(routes, req, url)
85
+ if (response) return response
86
+
87
+ if (url.pathname === '/' && req.method === 'GET') {
88
+ const sessions = sessionManager.list()
89
+ if (sessions.length > 0) {
90
+ const latest = sessions.sort(
91
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
92
+ )[0]!
93
+ return Response.redirect(`/s/${latest.id}`, 302)
94
+ }
95
+ return new Response(JSON.stringify({ message: 'No active sessions', sessions: [] }), {
96
+ headers: { 'Content-Type': 'application/json' },
97
+ })
98
+ }
99
+
100
+ return new Response(JSON.stringify({ error: 'Not found' }), {
101
+ status: 404,
102
+ headers: { 'Content-Type': 'application/json' },
103
+ })
104
+ },
105
+ websocket: wsManager.handlers,
106
+ })
107
+
108
+ const boundPort = server.port ?? port
109
+ portRef.value = boundPort
110
+
111
+ return {
112
+ server,
113
+ port: boundPort,
114
+ sessionManager,
115
+ stop() {
116
+ try {
117
+ unwatchAll()
118
+ } catch {
119
+ }
120
+ server.stop(true)
121
+ },
122
+ }
123
+ }
@@ -0,0 +1,30 @@
1
+ import { createCanvasPaths } from '@fabrika/canvas-kernel/server'
2
+ import { existsSync } from 'fs'
3
+ import { homedir, tmpdir } from 'os'
4
+ import { join, resolve } from 'path'
5
+
6
+ export const NEUTRAL_DATA_DIR = join(homedir(), '.fabrika')
7
+ export const LEGACY_DATA_DIR = join(homedir(), '.claude', 'fabrika')
8
+
9
+ export function resolveDataDir(): string {
10
+ const explicit = process.env['FABRIKA_DATA_DIR']
11
+ if (explicit) return resolve(explicit)
12
+ if (existsSync(NEUTRAL_DATA_DIR)) return NEUTRAL_DATA_DIR
13
+ if (existsSync(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR
14
+ return NEUTRAL_DATA_DIR
15
+ }
16
+
17
+ export const paths = createCanvasPaths({
18
+ appName: 'fabrika',
19
+ dataDir: resolveDataDir(),
20
+ tempDir: join(tmpdir(), 'fabrika'),
21
+ })
22
+
23
+ export const DATA_DIR = paths.dataDir
24
+ export const SESSIONS_DIR = paths.sessionsDir
25
+ export const TEMP_DIR = paths.tempDir
26
+ export const PID_FILE = paths.pidFile
27
+ export const COMPILE_TEMP_DIR = paths.compileTempDir
28
+ export const UPLOADS_DIR = paths.uploadsDir
29
+
30
+ export const DEFAULT_PORT = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)