@demlik/tea 0.9.0 → 0.10.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/README.md CHANGED
@@ -47,3 +47,7 @@ The four Diátaxis quadrants live in [`docs/`](./docs/README.md):
47
47
  - [How-to guides](./docs/how-to/index.md) — goal-oriented directions for getting a specific job done.
48
48
  - [Reference](./docs/reference/index.md) — information-oriented API description, generated per public module.
49
49
  - [Explanation](./docs/explanation/index.md) — understanding-oriented discussion of how it works and why it is shaped this way.
50
+
51
+ Two more surfaces sit behind those, for people working on the library rather than using it:
52
+ [`.patterns/`](./.patterns/index.md) is how the code is shaped, and
53
+ [`.decisions/`](./.decisions/index.md) is why it was shaped that way.
@@ -38,6 +38,18 @@ interface LaneDriver {
38
38
  readonly session: string;
39
39
  readonly at?: string | null;
40
40
  }
41
+ interface ServeOptions extends LaneViewerOptions {
42
+ /** Default 5411. `0` takes any free port and reports it back on `url`. */
43
+ readonly port?: number;
44
+ /** Default `127.0.0.1` — this reads a machine's own disk and should stay on it. */
45
+ readonly host?: string;
46
+ }
47
+ /** A running dashboard. */
48
+ interface LaneViewerServer {
49
+ /** Where to point a browser. Carries the real port when `port: 0` was asked for. */
50
+ readonly url: string;
51
+ readonly close: () => Promise<void>;
52
+ }
41
53
  interface LaneViewerOptions {
42
54
  /** Every lane the reader should see. Called on each request; cache if slow. */
43
55
  readonly lanes: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;
@@ -75,5 +87,27 @@ interface LaneViewerOptions {
75
87
  * ```
76
88
  */
77
89
  declare function laneViewer(opts: LaneViewerOptions): (request: Request) => Promise<Response>;
90
+ /**
91
+ * Start the dashboard. This is the one most callers want.
92
+ *
93
+ * {@link laneViewer} hands back a `(Request) => Response` for a host that already has a server to
94
+ * mount it on. Everything BETWEEN that handler and a running page — creating the server, turning
95
+ * node's `IncomingMessage` into a `Request`, reading a POST body, streaming a response back so the
96
+ * event stream stays open — is the same in every host, and asking each one to write it is asking
97
+ * them to get it subtly wrong. Notably the streaming: buffer the response and `/api/stream` never
98
+ * delivers a frame, which looks like "the page does not update" and is nobody's obvious bug.
99
+ *
100
+ * So the three callbacks are the whole integration:
101
+ *
102
+ * ```ts
103
+ * const server = await serveLaneViewer({
104
+ * root: ".fabrika/lanes", // not read here — see `lanes`
105
+ * lanes: () => readMyLanes(),
106
+ * transition: (r) => myVerb(r),
107
+ * });
108
+ * console.log(server.url);
109
+ * ```
110
+ */
111
+ declare function serveLaneViewer(opts: ServeOptions): Promise<LaneViewerServer>;
78
112
 
79
- export { type LaneDriver, type LaneFiles, type LaneViewerOptions, type TransitionRequest, type TransitionResult, laneViewer };
113
+ export { type LaneDriver, type LaneFiles, type LaneViewerOptions, type LaneViewerServer, type ServeOptions, type TransitionRequest, type TransitionResult, laneViewer, serveLaneViewer };
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'fs/promises';
2
+ import { createServer } from 'http';
2
3
  import { resolve, normalize, join, extname } from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
 
@@ -112,7 +113,59 @@ function laneViewer(opts) {
112
113
  }
113
114
  };
114
115
  }
116
+ async function serveLaneViewer(opts) {
117
+ const handle = laneViewer(opts);
118
+ const host = opts.host ?? "127.0.0.1";
119
+ const server = createServer((req, res) => {
120
+ void (async () => {
121
+ const body = req.method === "POST" || req.method === "PUT" ? await new Promise((ok) => {
122
+ let read = "";
123
+ req.on("data", (chunk) => {
124
+ read += chunk;
125
+ });
126
+ req.on("end", () => ok(read));
127
+ }) : void 0;
128
+ const out = await handle(
129
+ new Request(`http://${host}${req.url ?? "/"}`, {
130
+ method: req.method ?? "GET",
131
+ ...body === void 0 ? {} : { body }
132
+ })
133
+ );
134
+ res.writeHead(out.status, Object.fromEntries(out.headers));
135
+ if (out.body === null) {
136
+ res.end();
137
+ return;
138
+ }
139
+ const reader = out.body.getReader();
140
+ try {
141
+ for (; ; ) {
142
+ const { done, value } = await reader.read();
143
+ if (done) break;
144
+ res.write(value);
145
+ }
146
+ } catch {
147
+ }
148
+ res.end();
149
+ })();
150
+ });
151
+ const port = await new Promise((ok, no) => {
152
+ server.once("error", no);
153
+ server.listen(opts.port ?? 5411, host, () => {
154
+ const addr = server.address();
155
+ ok(
156
+ typeof addr === "object" && addr !== null ? addr.port : opts.port ?? 5411
157
+ );
158
+ });
159
+ });
160
+ return {
161
+ url: `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`,
162
+ close: () => new Promise((ok) => {
163
+ server.closeAllConnections?.();
164
+ server.close(() => ok());
165
+ })
166
+ };
167
+ }
115
168
 
116
- export { laneViewer };
169
+ export { laneViewer, serveLaneViewer };
117
170
  //# sourceMappingURL=server.js.map
118
171
  //# sourceMappingURL=server.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/chart/lane/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// ═══════════════════════════════════════════════════════════════════════════\n// chart/lane/server — the dashboard, as something a host can SERVE.\n//\n// `./chart/lane/react` gives you the components and assumes you have a\n// bundler. A CLI does not, and the consumer this was written for — fabrika —\n// is a CLI: it already knows where the lanes are, how to fold one and how to\n// record an event, and what it does not have is a page.\n//\n// So this ships the page prebuilt and asks the host for the three facts only\n// the host can know:\n//\n// WHERE THE LANES ARE — it reads its own root; we never touch a filesystem.\n// HOW TO RECORD AN EVENT — its own verb, which stays the ONLY writer. The\n// page proposes and the host disposes; a refusal is an answer, not an\n// error, and it is displayed in the host's own words.\n// WHO HOLDS A LANE — optional, because ownership is not in the lane files at\n// all (fabrika keeps claims on GitHub), so plenty of hosts cannot answer\n// it. Absent means UNKNOWN and the page says nothing, never \"free\".\n//\n// Everything else — folding, deriving what is stuck, drawing the machines,\n// the attention ordering, the scrubbing — is the library's and is already\n// tested here. The host writes an adapter, not a dashboard.\n//\n// The handler is `(Request) => Promise<Response>`, so it mounts under Node's\n// `http`, Bun.serve, Hono, or a Worker without an adapter per framework.\n// ═══════════════════════════════════════════════════════════════════════════\nimport { readFile } from \"node:fs/promises\";\nimport { extname, join, normalize, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** One lane, as the two files a host already has on disk. */\nexport interface LaneFiles {\n /** The lane key — the issue number, or whatever the host names lanes by. */\n readonly id: string;\n /** `workflow.json`, verbatim. */\n readonly workflow: string;\n /** `events.jsonl`, verbatim. Empty for a lane that has not run yet. */\n readonly events: string;\n /**\n * Who sends each event, when the host knows.\n *\n * A workflow document records topology and never provenance, so without\n * this the page can see that a task cannot move and not that it is WAITING\n * ON SOMEONE — which is the question a reader opened it to answer.\n */\n readonly origins?: unknown;\n}\n\n/** What the host is asked to do when a reader presses a button. */\nexport interface TransitionRequest {\n readonly lane: string;\n readonly event: string;\n /** Absent on a single-task lane, exactly as the CLI verb allows. */\n readonly task?: string;\n}\n\n/**\n * The host's answer. A refusal is a legitimate one — the machine said no —\n * and `message` is shown to the reader verbatim, so write it for them.\n */\nexport interface TransitionResult {\n readonly ok: boolean;\n readonly message: string;\n}\n\n/** Who holds a lane, when the host can say. */\nexport interface LaneDriver {\n /** A person or account — what a reader recognises. */\n readonly login: string;\n /** The claim token, or any stable handle for the session holding it. */\n readonly session: string;\n readonly at?: string | null;\n}\n\nexport interface LaneViewerOptions {\n /** Every lane the reader should see. Called on each request; cache if slow. */\n readonly lanes: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;\n /**\n * Record one event. OMIT IT and the page becomes read-only — every lane is\n * still shown, and nothing offers to act. That is a real mode, not a\n * degraded one: a report of a run that finished has nothing to dispatch.\n */\n readonly transition?: (\n req: TransitionRequest,\n ) => TransitionResult | Promise<TransitionResult>;\n /** Ownership, when it is knowable. `null` for a lane nobody holds. */\n readonly drivers?: () => Promise<Readonly<Record<string, LaneDriver | null>>>;\n /** Shown in the footer — where these lanes were read from. */\n readonly source?: string;\n /**\n * How often to re-ask `lanes()` for the live stream, in ms. Default 2000.\n *\n * LIVENESS IS OURS, NOT THE HOST'S. A host could watch its own files and\n * push, and then every host would write that code and get it subtly\n * different. Re-asking is uninteresting and correct: `lanes()` is a read of\n * two files a driver appends to, the answer is compared before anything is\n * sent, and a quiet fleet costs one stat per lane per tick.\n */\n readonly pollMs?: number;\n}\n\nconst MIME: Readonly<Record<string, string>> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".woff2\": \"font/woff2\",\n};\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json; charset=utf-8\" },\n });\n\n/** Where the prebuilt page lives inside this package. */\nconst ASSETS = resolve(fileURLToPath(import.meta.url), \"..\", \"viewer\");\n\n/**\n * The page, plus the small JSON contract it speaks.\n *\n * ```ts\n * const handle = laneViewer({\n * lanes: () => readMyLanes(),\n * transition: (r) => myTransitionVerb(r),\n * });\n * // Node:\n * createServer(async (req, res) => { … await handle(request) … });\n * ```\n */\nexport function laneViewer(\n opts: LaneViewerOptions,\n): (request: Request) => Promise<Response> {\n return async (request: Request): Promise<Response> => {\n const url = new URL(request.url);\n const path = url.pathname;\n\n if (path === \"/api/lanes\") {\n return json({ lanes: await opts.lanes(), source: opts.source ?? \"\" });\n }\n\n if (path === \"/api/drivers\") {\n if (opts.drivers === undefined) return json({ drivers: {} });\n try {\n return json({ drivers: await opts.drivers() });\n } catch {\n // UNKNOWN is not \"nobody\" — an empty answer renders nothing, and a\n // lane shown free while someone holds it is what starts a second\n // driver on one piece of work.\n return json({ drivers: {} });\n }\n }\n\n if (path === \"/api/stream\") {\n const every = Math.max(250, opts.pollMs ?? 2000);\n let last = \"\";\n let timer: ReturnType<typeof setInterval> | undefined;\n const stream = new ReadableStream<Uint8Array>({\n start(controller) {\n const enc = new TextEncoder();\n const beat = async () => {\n try {\n const next = JSON.stringify(await opts.lanes());\n if (next === last) return;\n last = next;\n controller.enqueue(enc.encode(`data: ${next}\\n\\n`));\n } catch {\n // a half-written append is the next event arriving, not a fault\n }\n };\n void beat();\n timer = setInterval(() => void beat(), every);\n },\n cancel() {\n if (timer !== undefined) clearInterval(timer);\n },\n });\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-store\",\n connection: \"keep-alive\",\n },\n });\n }\n\n if (path === \"/api/transition\") {\n if (request.method !== \"POST\") return json({ ok: false }, 405);\n if (opts.transition === undefined) {\n return json(\n {\n ok: false,\n exit: 1,\n stdout: \"\",\n stderr: \"this viewer is read-only\",\n },\n 200,\n );\n }\n let body: TransitionRequest;\n try {\n body = (await request.json()) as TransitionRequest;\n } catch {\n return json(\n { ok: false, exit: 1, stdout: \"\", stderr: \"malformed request\" },\n 400,\n );\n }\n const out = await opts.transition(body);\n // the page reads `{ok, exit, stdout, stderr}` — a host that speaks in\n // sentences rather than exit codes says the same thing in `stderr`.\n return json({\n ok: out.ok,\n exit: out.ok ? 0 : 1,\n stdout: out.ok ? out.message : \"\",\n stderr: out.ok ? \"\" : out.message,\n });\n }\n\n // ── the page itself ────────────────────────────────────────────────\n // Anything that is not the contract is an asset, and anything that is\n // not an asset is the page — a dashboard is one screen, so an unknown\n // path is a reader who typed something, not a 404 worth showing them.\n const rel = path === \"/\" ? \"index.html\" : path.replace(/^\\/+/, \"\");\n const file = normalize(join(ASSETS, rel));\n if (!file.startsWith(ASSETS)) return new Response(\"no\", { status: 403 });\n\n try {\n const bytes = await readFile(file);\n return new Response(new Uint8Array(bytes), {\n headers: {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n },\n });\n } catch {\n const html = await readFile(join(ASSETS, \"index.html\"));\n return new Response(new Uint8Array(html), {\n headers: { \"content-type\": MIME[\".html\"] as string },\n });\n }\n };\n}\n"]}
1
+ {"version":3,"sources":["../../../src/chart/lane/server.ts"],"names":[],"mappings":";;;;;;AAqHA,IAAM,IAAA,GAAyC;AAAA,EAC7C,OAAA,EAAS,0BAAA;AAAA,EACT,KAAA,EAAO,gCAAA;AAAA,EACP,MAAA,EAAQ,yBAAA;AAAA,EACR,OAAA,EAAS,iCAAA;AAAA,EACT,MAAA,EAAQ,eAAA;AAAA,EACR,QAAA,EAAU;AACZ,CAAA;AAEA,IAAM,IAAA,GAAO,CAAC,IAAA,EAAe,MAAA,GAAS,GAAA,KACpC,IAAI,QAAA,CAAS,IAAA,CAAK,SAAA,CAAU,IAAI,CAAA,EAAG;AAAA,EACjC,MAAA;AAAA,EACA,OAAA,EAAS,EAAE,cAAA,EAAgB,iCAAA;AAC7B,CAAC,CAAA;AAGH,IAAM,SAAS,OAAA,CAAQ,aAAA,CAAc,YAAY,GAAG,CAAA,EAAG,MAAM,QAAQ,CAAA;AAc9D,SAAS,WACd,IAAA,EACyC;AACzC,EAAA,OAAO,OAAO,OAAA,KAAwC;AACpD,IAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,OAAA,CAAQ,GAAG,CAAA;AAC/B,IAAA,MAAM,OAAO,GAAA,CAAI,QAAA;AAEjB,IAAA,IAAI,SAAS,YAAA,EAAc;AACzB,MAAA,OAAO,IAAA,CAAK,EAAE,KAAA,EAAO,MAAM,IAAA,CAAK,KAAA,EAAM,EAAG,MAAA,EAAQ,IAAA,CAAK,MAAA,IAAU,EAAA,EAAI,CAAA;AAAA,IACtE;AAEA,IAAA,IAAI,SAAS,cAAA,EAAgB;AAC3B,MAAA,IAAI,IAAA,CAAK,YAAY,MAAA,EAAW,OAAO,KAAK,EAAE,OAAA,EAAS,EAAC,EAAG,CAAA;AAC3D,MAAA,IAAI;AACF,QAAA,OAAO,KAAK,EAAE,OAAA,EAAS,MAAM,IAAA,CAAK,OAAA,IAAW,CAAA;AAAA,MAC/C,CAAA,CAAA,MAAQ;AAIN,QAAA,OAAO,IAAA,CAAK,EAAE,OAAA,EAAS,IAAI,CAAA;AAAA,MAC7B;AAAA,IACF;AAEA,IAAA,IAAI,SAAS,aAAA,EAAe;AAC1B,MAAA,MAAM,QAAQ,IAAA,CAAK,GAAA,CAAI,GAAA,EAAK,IAAA,CAAK,UAAU,GAAI,CAAA;AAC/C,MAAA,IAAI,IAAA,GAAO,EAAA;AACX,MAAA,IAAI,KAAA;AACJ,MAAA,MAAM,MAAA,GAAS,IAAI,cAAA,CAA2B;AAAA,QAC5C,MAAM,UAAA,EAAY;AAChB,UAAA,MAAM,GAAA,GAAM,IAAI,WAAA,EAAY;AAC5B,UAAA,MAAM,OAAO,YAAY;AACvB,YAAA,IAAI;AACF,cAAA,MAAM,OAAO,IAAA,CAAK,SAAA,CAAU,MAAM,IAAA,CAAK,OAAO,CAAA;AAC9C,cAAA,IAAI,SAAS,IAAA,EAAM;AACnB,cAAA,IAAA,GAAO,IAAA;AACP,cAAA,UAAA,CAAW,OAAA,CAAQ,GAAA,CAAI,MAAA,CAAO,CAAA,MAAA,EAAS,IAAI;;AAAA,CAAM,CAAC,CAAA;AAAA,YACpD,CAAA,CAAA,MAAQ;AAAA,YAER;AAAA,UACF,CAAA;AACA,UAAA,KAAK,IAAA,EAAK;AACV,UAAA,KAAA,GAAQ,WAAA,CAAY,MAAM,KAAK,IAAA,IAAQ,KAAK,CAAA;AAAA,QAC9C,CAAA;AAAA,QACA,MAAA,GAAS;AACP,UAAA,IAAI,KAAA,KAAU,MAAA,EAAW,aAAA,CAAc,KAAK,CAAA;AAAA,QAC9C;AAAA,OACD,CAAA;AACD,MAAA,OAAO,IAAI,SAAS,MAAA,EAAQ;AAAA,QAC1B,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,kCAAA;AAAA,UAChB,eAAA,EAAiB,UAAA;AAAA,UACjB,UAAA,EAAY;AAAA;AACd,OACD,CAAA;AAAA,IACH;AAEA,IAAA,IAAI,SAAS,iBAAA,EAAmB;AAC9B,MAAA,IAAI,OAAA,CAAQ,WAAW,MAAA,EAAQ,OAAO,KAAK,EAAE,EAAA,EAAI,KAAA,EAAM,EAAG,GAAG,CAAA;AAC7D,MAAA,IAAI,IAAA,CAAK,eAAe,MAAA,EAAW;AACjC,QAAA,OAAO,IAAA;AAAA,UACL;AAAA,YACE,EAAA,EAAI,KAAA;AAAA,YACJ,IAAA,EAAM,CAAA;AAAA,YACN,MAAA,EAAQ,EAAA;AAAA,YACR,MAAA,EAAQ;AAAA,WACV;AAAA,UACA;AAAA,SACF;AAAA,MACF;AACA,MAAA,IAAI,IAAA;AACJ,MAAA,IAAI;AACF,QAAA,IAAA,GAAQ,MAAM,QAAQ,IAAA,EAAK;AAAA,MAC7B,CAAA,CAAA,MAAQ;AACN,QAAA,OAAO,IAAA;AAAA,UACL,EAAE,IAAI,KAAA,EAAO,IAAA,EAAM,GAAG,MAAA,EAAQ,EAAA,EAAI,QAAQ,mBAAA,EAAoB;AAAA,UAC9D;AAAA,SACF;AAAA,MACF;AACA,MAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,UAAA,CAAW,IAAI,CAAA;AAGtC,MAAA,OAAO,IAAA,CAAK;AAAA,QACV,IAAI,GAAA,CAAI,EAAA;AAAA,QACR,IAAA,EAAM,GAAA,CAAI,EAAA,GAAK,CAAA,GAAI,CAAA;AAAA,QACnB,MAAA,EAAQ,GAAA,CAAI,EAAA,GAAK,GAAA,CAAI,OAAA,GAAU,EAAA;AAAA,QAC/B,MAAA,EAAQ,GAAA,CAAI,EAAA,GAAK,EAAA,GAAK,GAAA,CAAI;AAAA,OAC3B,CAAA;AAAA,IACH;AAMA,IAAA,MAAM,MAAM,IAAA,KAAS,GAAA,GAAM,eAAe,IAAA,CAAK,OAAA,CAAQ,QAAQ,EAAE,CAAA;AACjE,IAAA,MAAM,IAAA,GAAO,SAAA,CAAU,IAAA,CAAK,MAAA,EAAQ,GAAG,CAAC,CAAA;AACxC,IAAA,IAAI,CAAC,IAAA,CAAK,UAAA,CAAW,MAAM,CAAA,EAAG,OAAO,IAAI,QAAA,CAAS,IAAA,EAAM,EAAE,MAAA,EAAQ,GAAA,EAAK,CAAA;AAEvE,IAAA,IAAI;AACF,MAAA,MAAM,KAAA,GAAQ,MAAM,QAAA,CAAS,IAAI,CAAA;AACjC,MAAA,OAAO,IAAI,QAAA,CAAS,IAAI,UAAA,CAAW,KAAK,CAAA,EAAG;AAAA,QACzC,OAAA,EAAS;AAAA,UACP,cAAA,EAAgB,IAAA,CAAK,OAAA,CAAQ,IAAI,CAAC,CAAA,IAAK;AAAA;AACzC,OACD,CAAA;AAAA,IACH,CAAA,CAAA,MAAQ;AACN,MAAA,MAAM,OAAO,MAAM,QAAA,CAAS,IAAA,CAAK,MAAA,EAAQ,YAAY,CAAC,CAAA;AACtD,MAAA,OAAO,IAAI,QAAA,CAAS,IAAI,UAAA,CAAW,IAAI,CAAA,EAAG;AAAA,QACxC,OAAA,EAAS,EAAE,cAAA,EAAgB,IAAA,CAAK,OAAO,CAAA;AAAY,OACpD,CAAA;AAAA,IACH;AAAA,EACF,CAAA;AACF;AAuBA,eAAsB,gBACpB,IAAA,EAC2B;AAC3B,EAAA,MAAM,MAAA,GAAS,WAAW,IAAI,CAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,KAAK,IAAA,IAAQ,WAAA;AAE1B,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,CAAC,GAAA,EAAK,GAAA,KAAQ;AACxC,IAAA,KAAA,CAAM,YAAY;AAChB,MAAA,MAAM,IAAA,GACJ,GAAA,CAAI,MAAA,KAAW,MAAA,IAAU,GAAA,CAAI,MAAA,KAAW,KAAA,GACpC,MAAM,IAAI,OAAA,CAAgB,CAAC,EAAA,KAAO;AAChC,QAAA,IAAI,IAAA,GAAO,EAAA;AACX,QAAA,GAAA,CAAI,EAAA,CAAG,MAAA,EAAQ,CAAC,KAAA,KAAU;AACxB,UAAA,IAAA,IAAQ,KAAA;AAAA,QACV,CAAC,CAAA;AACD,QAAA,GAAA,CAAI,EAAA,CAAG,KAAA,EAAO,MAAM,EAAA,CAAG,IAAI,CAAC,CAAA;AAAA,MAC9B,CAAC,CAAA,GACD,MAAA;AAEN,MAAA,MAAM,MAAM,MAAM,MAAA;AAAA,QAChB,IAAI,QAAQ,CAAA,OAAA,EAAU,IAAI,GAAG,GAAA,CAAI,GAAA,IAAO,GAAG,CAAA,CAAA,EAAI;AAAA,UAC7C,MAAA,EAAQ,IAAI,MAAA,IAAU,KAAA;AAAA,UACtB,GAAI,IAAA,KAAS,MAAA,GAAY,EAAC,GAAI,EAAE,IAAA;AAAK,SACtC;AAAA,OACH;AAEA,MAAA,GAAA,CAAI,UAAU,GAAA,CAAI,MAAA,EAAQ,OAAO,WAAA,CAAY,GAAA,CAAI,OAAO,CAAC,CAAA;AACzD,MAAA,IAAI,GAAA,CAAI,SAAS,IAAA,EAAM;AACrB,QAAA,GAAA,CAAI,GAAA,EAAI;AACR,QAAA;AAAA,MACF;AAGA,MAAA,MAAM,MAAA,GAAS,GAAA,CAAI,IAAA,CAAK,SAAA,EAAU;AAClC,MAAA,IAAI;AACF,QAAA,WAAS;AACP,UAAA,MAAM,EAAE,IAAA,EAAM,KAAA,EAAM,GAAI,MAAM,OAAO,IAAA,EAAK;AAC1C,UAAA,IAAI,IAAA,EAAM;AACV,UAAA,GAAA,CAAI,MAAM,KAAK,CAAA;AAAA,QACjB;AAAA,MACF,CAAA,CAAA,MAAQ;AAAA,MAER;AACA,MAAA,GAAA,CAAI,GAAA,EAAI;AAAA,IACV,CAAA,GAAG;AAAA,EACL,CAAC,CAAA;AAED,EAAA,MAAM,OAAO,MAAM,IAAI,OAAA,CAAgB,CAAC,IAAI,EAAA,KAAO;AACjD,IAAA,MAAA,CAAO,IAAA,CAAK,SAAS,EAAE,CAAA;AACvB,IAAA,MAAA,CAAO,MAAA,CAAO,IAAA,CAAK,IAAA,IAAQ,IAAA,EAAM,MAAM,MAAM;AAC3C,MAAA,MAAM,IAAA,GAAO,OAAO,OAAA,EAAQ;AAC5B,MAAA,EAAA;AAAA,QACE,OAAO,SAAS,QAAA,IAAY,IAAA,KAAS,OACjC,IAAA,CAAK,IAAA,GACJ,KAAK,IAAA,IAAQ;AAAA,OACpB;AAAA,IACF,CAAC,CAAA;AAAA,EACH,CAAC,CAAA;AAED,EAAA,OAAO;AAAA,IACL,KAAK,CAAA,OAAA,EAAU,IAAA,KAAS,YAAY,WAAA,GAAc,IAAI,IAAI,IAAI,CAAA,CAAA;AAAA,IAC9D,KAAA,EAAO,MACL,IAAI,OAAA,CAAc,CAAC,EAAA,KAAO;AACxB,MAAA,MAAA,CAAO,mBAAA,IAAsB;AAC7B,MAAA,MAAA,CAAO,KAAA,CAAM,MAAM,EAAA,EAAI,CAAA;AAAA,IACzB,CAAC;AAAA,GACL;AACF","file":"server.js","sourcesContent":["// ═══════════════════════════════════════════════════════════════════════════\n// chart/lane/server — the dashboard, as something a host can SERVE.\n//\n// `./chart/lane/react` gives you the components and assumes you have a\n// bundler. A CLI does not, and the consumer this was written for — fabrika —\n// is a CLI: it already knows where the lanes are, how to fold one and how to\n// record an event, and what it does not have is a page.\n//\n// So this ships the page prebuilt and asks the host for the three facts only\n// the host can know:\n//\n// WHERE THE LANES ARE — it reads its own root; we never touch a filesystem.\n// HOW TO RECORD AN EVENT — its own verb, which stays the ONLY writer. The\n// page proposes and the host disposes; a refusal is an answer, not an\n// error, and it is displayed in the host's own words.\n// WHO HOLDS A LANE — optional, because ownership is not in the lane files at\n// all (fabrika keeps claims on GitHub), so plenty of hosts cannot answer\n// it. Absent means UNKNOWN and the page says nothing, never \"free\".\n//\n// Everything else — folding, deriving what is stuck, drawing the machines,\n// the attention ordering, the scrubbing — is the library's and is already\n// tested here. The host writes an adapter, not a dashboard.\n//\n// The handler is `(Request) => Promise<Response>`, so it mounts under Node's\n// `http`, Bun.serve, Hono, or a Worker without an adapter per framework.\n// ═══════════════════════════════════════════════════════════════════════════\n\nimport { readFile } from \"node:fs/promises\";\nimport { createServer } from \"node:http\";\nimport { extname, join, normalize, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\n/** One lane, as the two files a host already has on disk. */\nexport interface LaneFiles {\n /** The lane key — the issue number, or whatever the host names lanes by. */\n readonly id: string;\n /** `workflow.json`, verbatim. */\n readonly workflow: string;\n /** `events.jsonl`, verbatim. Empty for a lane that has not run yet. */\n readonly events: string;\n /**\n * Who sends each event, when the host knows.\n *\n * A workflow document records topology and never provenance, so without\n * this the page can see that a task cannot move and not that it is WAITING\n * ON SOMEONE — which is the question a reader opened it to answer.\n */\n readonly origins?: unknown;\n}\n\n/** What the host is asked to do when a reader presses a button. */\nexport interface TransitionRequest {\n readonly lane: string;\n readonly event: string;\n /** Absent on a single-task lane, exactly as the CLI verb allows. */\n readonly task?: string;\n}\n\n/**\n * The host's answer. A refusal is a legitimate one — the machine said no —\n * and `message` is shown to the reader verbatim, so write it for them.\n */\nexport interface TransitionResult {\n readonly ok: boolean;\n readonly message: string;\n}\n\n/** Who holds a lane, when the host can say. */\nexport interface LaneDriver {\n /** A person or account — what a reader recognises. */\n readonly login: string;\n /** The claim token, or any stable handle for the session holding it. */\n readonly session: string;\n readonly at?: string | null;\n}\n\nexport interface ServeOptions extends LaneViewerOptions {\n /** Default 5411. `0` takes any free port and reports it back on `url`. */\n readonly port?: number;\n /** Default `127.0.0.1` — this reads a machine's own disk and should stay on it. */\n readonly host?: string;\n}\n\n/** A running dashboard. */\nexport interface LaneViewerServer {\n /** Where to point a browser. Carries the real port when `port: 0` was asked for. */\n readonly url: string;\n readonly close: () => Promise<void>;\n}\n\nexport interface LaneViewerOptions {\n /** Every lane the reader should see. Called on each request; cache if slow. */\n readonly lanes: () => readonly LaneFiles[] | Promise<readonly LaneFiles[]>;\n /**\n * Record one event. OMIT IT and the page becomes read-only — every lane is\n * still shown, and nothing offers to act. That is a real mode, not a\n * degraded one: a report of a run that finished has nothing to dispatch.\n */\n readonly transition?: (\n req: TransitionRequest,\n ) => TransitionResult | Promise<TransitionResult>;\n /** Ownership, when it is knowable. `null` for a lane nobody holds. */\n readonly drivers?: () => Promise<Readonly<Record<string, LaneDriver | null>>>;\n /** Shown in the footer — where these lanes were read from. */\n readonly source?: string;\n /**\n * How often to re-ask `lanes()` for the live stream, in ms. Default 2000.\n *\n * LIVENESS IS OURS, NOT THE HOST'S. A host could watch its own files and\n * push, and then every host would write that code and get it subtly\n * different. Re-asking is uninteresting and correct: `lanes()` is a read of\n * two files a driver appends to, the answer is compared before anything is\n * sent, and a quiet fleet costs one stat per lane per tick.\n */\n readonly pollMs?: number;\n}\n\nconst MIME: Readonly<Record<string, string>> = {\n \".html\": \"text/html; charset=utf-8\",\n \".js\": \"text/javascript; charset=utf-8\",\n \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json; charset=utf-8\",\n \".svg\": \"image/svg+xml\",\n \".woff2\": \"font/woff2\",\n};\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), {\n status,\n headers: { \"content-type\": \"application/json; charset=utf-8\" },\n });\n\n/** Where the prebuilt page lives inside this package. */\nconst ASSETS = resolve(fileURLToPath(import.meta.url), \"..\", \"viewer\");\n\n/**\n * The page, plus the small JSON contract it speaks.\n *\n * ```ts\n * const handle = laneViewer({\n * lanes: () => readMyLanes(),\n * transition: (r) => myTransitionVerb(r),\n * });\n * // Node:\n * createServer(async (req, res) => { … await handle(request) … });\n * ```\n */\nexport function laneViewer(\n opts: LaneViewerOptions,\n): (request: Request) => Promise<Response> {\n return async (request: Request): Promise<Response> => {\n const url = new URL(request.url);\n const path = url.pathname;\n\n if (path === \"/api/lanes\") {\n return json({ lanes: await opts.lanes(), source: opts.source ?? \"\" });\n }\n\n if (path === \"/api/drivers\") {\n if (opts.drivers === undefined) return json({ drivers: {} });\n try {\n return json({ drivers: await opts.drivers() });\n } catch {\n // UNKNOWN is not \"nobody\" — an empty answer renders nothing, and a\n // lane shown free while someone holds it is what starts a second\n // driver on one piece of work.\n return json({ drivers: {} });\n }\n }\n\n if (path === \"/api/stream\") {\n const every = Math.max(250, opts.pollMs ?? 2000);\n let last = \"\";\n let timer: ReturnType<typeof setInterval> | undefined;\n const stream = new ReadableStream<Uint8Array>({\n start(controller) {\n const enc = new TextEncoder();\n const beat = async () => {\n try {\n const next = JSON.stringify(await opts.lanes());\n if (next === last) return;\n last = next;\n controller.enqueue(enc.encode(`data: ${next}\\n\\n`));\n } catch {\n // a half-written append is the next event arriving, not a fault\n }\n };\n void beat();\n timer = setInterval(() => void beat(), every);\n },\n cancel() {\n if (timer !== undefined) clearInterval(timer);\n },\n });\n return new Response(stream, {\n headers: {\n \"content-type\": \"text/event-stream; charset=utf-8\",\n \"cache-control\": \"no-store\",\n connection: \"keep-alive\",\n },\n });\n }\n\n if (path === \"/api/transition\") {\n if (request.method !== \"POST\") return json({ ok: false }, 405);\n if (opts.transition === undefined) {\n return json(\n {\n ok: false,\n exit: 1,\n stdout: \"\",\n stderr: \"this viewer is read-only\",\n },\n 200,\n );\n }\n let body: TransitionRequest;\n try {\n body = (await request.json()) as TransitionRequest;\n } catch {\n return json(\n { ok: false, exit: 1, stdout: \"\", stderr: \"malformed request\" },\n 400,\n );\n }\n const out = await opts.transition(body);\n // the page reads `{ok, exit, stdout, stderr}` — a host that speaks in\n // sentences rather than exit codes says the same thing in `stderr`.\n return json({\n ok: out.ok,\n exit: out.ok ? 0 : 1,\n stdout: out.ok ? out.message : \"\",\n stderr: out.ok ? \"\" : out.message,\n });\n }\n\n // ── the page itself ────────────────────────────────────────────────\n // Anything that is not the contract is an asset, and anything that is\n // not an asset is the page — a dashboard is one screen, so an unknown\n // path is a reader who typed something, not a 404 worth showing them.\n const rel = path === \"/\" ? \"index.html\" : path.replace(/^\\/+/, \"\");\n const file = normalize(join(ASSETS, rel));\n if (!file.startsWith(ASSETS)) return new Response(\"no\", { status: 403 });\n\n try {\n const bytes = await readFile(file);\n return new Response(new Uint8Array(bytes), {\n headers: {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n },\n });\n } catch {\n const html = await readFile(join(ASSETS, \"index.html\"));\n return new Response(new Uint8Array(html), {\n headers: { \"content-type\": MIME[\".html\"] as string },\n });\n }\n };\n}\n\n/**\n * Start the dashboard. This is the one most callers want.\n *\n * {@link laneViewer} hands back a `(Request) => Response` for a host that already has a server to\n * mount it on. Everything BETWEEN that handler and a running page — creating the server, turning\n * node's `IncomingMessage` into a `Request`, reading a POST body, streaming a response back so the\n * event stream stays open — is the same in every host, and asking each one to write it is asking\n * them to get it subtly wrong. Notably the streaming: buffer the response and `/api/stream` never\n * delivers a frame, which looks like \"the page does not update\" and is nobody's obvious bug.\n *\n * So the three callbacks are the whole integration:\n *\n * ```ts\n * const server = await serveLaneViewer({\n * root: \".fabrika/lanes\", // not read here — see `lanes`\n * lanes: () => readMyLanes(),\n * transition: (r) => myVerb(r),\n * });\n * console.log(server.url);\n * ```\n */\nexport async function serveLaneViewer(\n opts: ServeOptions,\n): Promise<LaneViewerServer> {\n const handle = laneViewer(opts);\n const host = opts.host ?? \"127.0.0.1\";\n\n const server = createServer((req, res) => {\n void (async () => {\n const body =\n req.method === \"POST\" || req.method === \"PUT\"\n ? await new Promise<string>((ok) => {\n let read = \"\";\n req.on(\"data\", (chunk) => {\n read += chunk;\n });\n req.on(\"end\", () => ok(read));\n })\n : undefined;\n\n const out = await handle(\n new Request(`http://${host}${req.url ?? \"/\"}`, {\n method: req.method ?? \"GET\",\n ...(body === undefined ? {} : { body }),\n }),\n );\n\n res.writeHead(out.status, Object.fromEntries(out.headers));\n if (out.body === null) {\n res.end();\n return;\n }\n // STREAMED, never buffered: `/api/stream` holds open for the life of the\n // page, and a buffered response is one that never arrives.\n const reader = out.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n res.write(value);\n }\n } catch {\n // the reader is gone — the page navigated away mid-frame\n }\n res.end();\n })();\n });\n\n const port = await new Promise<number>((ok, no) => {\n server.once(\"error\", no);\n server.listen(opts.port ?? 5411, host, () => {\n const addr = server.address();\n ok(\n typeof addr === \"object\" && addr !== null\n ? addr.port\n : (opts.port ?? 5411),\n );\n });\n });\n\n return {\n url: `http://${host === \"0.0.0.0\" ? \"localhost\" : host}:${port}`,\n close: () =>\n new Promise<void>((ok) => {\n server.closeAllConnections?.();\n server.close(() => ok());\n }),\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demlik/tea",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "TEA-faithful state machine substrate — one package, every host adapter. Core + subs + testing, plus React, Durable Object, Chrome extension, Node, in-memory, work-queue, devtools, and property-testing modules as subpaths.",
5
5
  "keywords": [
6
6
  "tea",