@flow-as-code/cli 0.1.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 (120) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +446 -0
  3. package/dist/aws.d.ts +28 -0
  4. package/dist/aws.d.ts.map +1 -0
  5. package/dist/aws.js +104 -0
  6. package/dist/aws.js.map +1 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +132 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/bridge/exportFiles.d.ts +16 -0
  12. package/dist/bridge/exportFiles.d.ts.map +1 -0
  13. package/dist/bridge/exportFiles.js +95 -0
  14. package/dist/bridge/exportFiles.js.map +1 -0
  15. package/dist/bridge/pair.d.ts +103 -0
  16. package/dist/bridge/pair.d.ts.map +1 -0
  17. package/dist/bridge/pair.js +219 -0
  18. package/dist/bridge/pair.js.map +1 -0
  19. package/dist/bridge/protocol.d.ts +187 -0
  20. package/dist/bridge/protocol.d.ts.map +1 -0
  21. package/dist/bridge/protocol.js +132 -0
  22. package/dist/bridge/protocol.js.map +1 -0
  23. package/dist/bridge/server.d.ts +95 -0
  24. package/dist/bridge/server.d.ts.map +1 -0
  25. package/dist/bridge/server.js +669 -0
  26. package/dist/bridge/server.js.map +1 -0
  27. package/dist/cdk-scaffold.d.ts +16 -0
  28. package/dist/cdk-scaffold.d.ts.map +1 -0
  29. package/dist/cdk-scaffold.js +28 -0
  30. package/dist/cdk-scaffold.js.map +1 -0
  31. package/dist/codegen.d.ts +5 -0
  32. package/dist/codegen.d.ts.map +1 -0
  33. package/dist/codegen.js +27 -0
  34. package/dist/codegen.js.map +1 -0
  35. package/dist/diff.d.ts +33 -0
  36. package/dist/diff.d.ts.map +1 -0
  37. package/dist/diff.js +225 -0
  38. package/dist/diff.js.map +1 -0
  39. package/dist/docs.d.ts +46 -0
  40. package/dist/docs.d.ts.map +1 -0
  41. package/dist/docs.js +223 -0
  42. package/dist/docs.js.map +1 -0
  43. package/dist/emit.d.ts +8 -0
  44. package/dist/emit.d.ts.map +1 -0
  45. package/dist/emit.js +55 -0
  46. package/dist/emit.js.map +1 -0
  47. package/dist/errors.d.ts +12 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +25 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/export.d.ts +27 -0
  52. package/dist/export.d.ts.map +1 -0
  53. package/dist/export.js +126 -0
  54. package/dist/export.js.map +1 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +23 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/lint.d.ts +6 -0
  60. package/dist/lint.d.ts.map +1 -0
  61. package/dist/lint.js +31 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/render.d.ts +6 -0
  64. package/dist/render.d.ts.map +1 -0
  65. package/dist/render.js +49 -0
  66. package/dist/render.js.map +1 -0
  67. package/dist/run.d.ts +13 -0
  68. package/dist/run.d.ts.map +1 -0
  69. package/dist/run.js +38 -0
  70. package/dist/run.js.map +1 -0
  71. package/dist/simulate.d.ts +35 -0
  72. package/dist/simulate.d.ts.map +1 -0
  73. package/dist/simulate.js +194 -0
  74. package/dist/simulate.js.map +1 -0
  75. package/dist/studio.d.ts +23 -0
  76. package/dist/studio.d.ts.map +1 -0
  77. package/dist/studio.js +120 -0
  78. package/dist/studio.js.map +1 -0
  79. package/dist/synth-resolve-hook.d.ts +19 -0
  80. package/dist/synth-resolve-hook.d.ts.map +1 -0
  81. package/dist/synth-resolve-hook.js +127 -0
  82. package/dist/synth-resolve-hook.js.map +1 -0
  83. package/dist/synth-runner.d.ts +12 -0
  84. package/dist/synth-runner.d.ts.map +1 -0
  85. package/dist/synth-runner.js +145 -0
  86. package/dist/synth-runner.js.map +1 -0
  87. package/dist/synth.d.ts +55 -0
  88. package/dist/synth.d.ts.map +1 -0
  89. package/dist/synth.js +327 -0
  90. package/dist/synth.js.map +1 -0
  91. package/dist/watch.d.ts +66 -0
  92. package/dist/watch.d.ts.map +1 -0
  93. package/dist/watch.js +293 -0
  94. package/dist/watch.js.map +1 -0
  95. package/package.json +85 -0
  96. package/schema/flowdoc-0.1.schema.json +669 -0
  97. package/schema/scenario-0.1.schema.json +318 -0
  98. package/src/aws.ts +149 -0
  99. package/src/bin.ts +179 -0
  100. package/src/bridge/exportFiles.ts +116 -0
  101. package/src/bridge/pair.ts +312 -0
  102. package/src/bridge/protocol.ts +289 -0
  103. package/src/bridge/server.ts +827 -0
  104. package/src/cdk-scaffold.ts +41 -0
  105. package/src/codegen.ts +36 -0
  106. package/src/diff.ts +279 -0
  107. package/src/docs.ts +249 -0
  108. package/src/emit.ts +69 -0
  109. package/src/errors.ts +27 -0
  110. package/src/export.ts +167 -0
  111. package/src/index.ts +27 -0
  112. package/src/lint.ts +42 -0
  113. package/src/render.ts +58 -0
  114. package/src/run.ts +42 -0
  115. package/src/simulate.ts +244 -0
  116. package/src/studio.ts +143 -0
  117. package/src/synth-resolve-hook.ts +152 -0
  118. package/src/synth-runner.ts +157 -0
  119. package/src/synth.ts +380 -0
  120. package/src/watch.ts +388 -0
@@ -0,0 +1,827 @@
1
+ /*
2
+ * Copyright 2026 The flow-as-code Authors
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ // The `flow-cli studio` local bridge: static studio assets plus the small JSON
6
+ // API in ./protocol.ts, over node:http and nothing else.
7
+ //
8
+ // Local-first is a hard rule (CLAUDE.md), so the shape of this server is
9
+ // deliberate:
10
+ //
11
+ // - It binds 127.0.0.1 and only 127.0.0.1. Never 0.0.0.0, never a LAN
12
+ // address: this process writes files the user owns, and a bridge reachable
13
+ // from the network is a remote file writer. The bind address is not
14
+ // configurable for that reason.
15
+ // - It also checks the Host header, because binding the loopback interface
16
+ // is not by itself protection against DNS rebinding: a page on the public
17
+ // internet can resolve its own hostname to 127.0.0.1 and post to this
18
+ // port. A request whose Host is not localhost is refused.
19
+ // - Every API request must present the session token, and requests carrying
20
+ // a foreign Origin or Sec-Fetch-Site are refused. Withholding CORS headers
21
+ // is NOT sufficient on its own: it stops a cross-origin page reading the
22
+ // reply, but a write does not need the reply. See forgery.test.ts.
23
+ // - No CORS headers are ever sent, so a cross-origin page cannot read a
24
+ // response even if it manages to send a request.
25
+ // - It serves files from ONE directory (the studio's built dist) and every
26
+ // resolved path is checked to be inside it. Documents are addressed by
27
+ // slug, never by path.
28
+ // - It makes no outbound connections of any kind.
29
+ //
30
+ // Writes are the reason the bridge exists: the studio is a browser page and
31
+ // cannot write files, so a canvas save is a PUT and this process generates the
32
+ // paired builder source and writes both halves (./pair.ts). The A04 watch
33
+ // engine covers the other direction and its events are relayed to the studio
34
+ // on the long-poll stream.
35
+
36
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
37
+ import { randomBytes, timingSafeEqual } from "node:crypto";
38
+ import { readFile, stat } from "node:fs/promises";
39
+ import { basename, extname, resolve, sep } from "node:path";
40
+
41
+ import { createWatcher, type FlowWatcher } from "../watch.js";
42
+ import type { FlowDoc } from "@flow-as-code/core";
43
+
44
+ import {
45
+ BRIDGE_PREFIX,
46
+ TOKEN_HEADER,
47
+ TOKEN_PARAM,
48
+ BRIDGE_PROTOCOL,
49
+ TS_SUFFIX,
50
+ bridgeBootScript,
51
+ type BridgeConflict,
52
+ type BridgeDocPayload,
53
+ type BridgeEvent,
54
+ type BridgeEventBatch,
55
+ type BridgeInfo,
56
+ type BridgeResolveRequest,
57
+ type BridgeWriteRequest,
58
+ isBridgeDocName,
59
+ } from "./protocol.js";
60
+ import { checkExportRequest, writeExport } from "./exportFiles.js";
61
+ import {
62
+ BridgeError,
63
+ PairConflict,
64
+ adoptCode,
65
+ ensureBuilderFiles,
66
+ listDocNames,
67
+ pairPaths,
68
+ readPair,
69
+ synthPair,
70
+ writePair,
71
+ type EnsureBuilderFilesResult,
72
+ } from "./pair.js";
73
+
74
+ /**
75
+ * A bridge event before it is published. Distributive by hand: `Omit` over a
76
+ * union keeps only the keys every member shares, which would erase the payload
77
+ * of all three event kinds.
78
+ */
79
+ type Unsequenced<T> = T extends { seq: number } ? Omit<T, "seq"> : never;
80
+ export type PendingEvent = Unsequenced<BridgeEvent>;
81
+
82
+ /** The one address this server may bind. See the note at the top of the file. */
83
+ export const BRIDGE_HOST = "127.0.0.1";
84
+
85
+ /** Hosts the Host header may name, port aside. */
86
+ const ALLOWED_HOSTS = new Set([BRIDGE_HOST, "localhost", "[::1]", "::1"]);
87
+
88
+ /** A parked event poll answers with an empty batch after this long. */
89
+ const DEFAULT_POLL_TIMEOUT_MS = 25_000;
90
+
91
+ /** Bodies larger than this are refused unread. */
92
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
93
+
94
+ /** Events kept for clients that are between polls. */
95
+ const EVENT_BUFFER = 500;
96
+
97
+ const MIME: Readonly<Record<string, string>> = {
98
+ ".html": "text/html; charset=utf-8",
99
+ ".js": "text/javascript; charset=utf-8",
100
+ ".mjs": "text/javascript; charset=utf-8",
101
+ ".css": "text/css; charset=utf-8",
102
+ ".json": "application/json; charset=utf-8",
103
+ ".map": "application/json; charset=utf-8",
104
+ ".svg": "image/svg+xml",
105
+ ".png": "image/png",
106
+ ".jpg": "image/jpeg",
107
+ ".webp": "image/webp",
108
+ ".ico": "image/x-icon",
109
+ ".woff": "font/woff",
110
+ ".woff2": "font/woff2",
111
+ ".txt": "text/plain; charset=utf-8",
112
+ };
113
+
114
+ export interface StudioServerOptions {
115
+ /** Directory of FlowDocs and builder files to serve. */
116
+ dir: string;
117
+ /**
118
+ * Directory of built studio assets. Omitted, the bridge serves the API only,
119
+ * which is what the tests use and what a `vite dev` session would proxy to.
120
+ */
121
+ assetsDir?: string;
122
+ /** 0 (the default) picks a free port. */
123
+ port?: number;
124
+ /** Watch `dir` for builder-file edits. Default true. */
125
+ watch?: boolean;
126
+ /**
127
+ * Write the missing `<name>.flow.ts` for every document in `dir` before
128
+ * serving it, so a directory of FlowDocs alone can start the edit-the-code
129
+ * loop. Default false; `flow-cli studio` turns it on. See ensureBuilderFiles.
130
+ */
131
+ ensurePairs?: boolean;
132
+ /** Shortened in tests; the default is a normal long-poll timeout. */
133
+ pollTimeoutMs?: number;
134
+ /** Fixed session token. Tests pin it; production generates one per run. */
135
+ token?: string;
136
+ /** Called for every published event, so the command can log a line. */
137
+ onEvent?: (event: BridgeEvent) => void;
138
+ }
139
+
140
+ export interface StudioServer {
141
+ readonly url: string;
142
+ /** Session token embedded in `url`; every bridge API call must present it. */
143
+ readonly token: string;
144
+ readonly host: string;
145
+ readonly port: number;
146
+ readonly dir: string;
147
+ /** What `ensurePairs` wrote and what it could not write. Empty when off. */
148
+ readonly prepared: EnsureBuilderFilesResult;
149
+ /** The underlying server, for tests that assert the bound address. */
150
+ readonly server: Server;
151
+ close(): Promise<void>;
152
+ }
153
+
154
+ interface ParkedPoll {
155
+ cursor: number;
156
+ respond: (batch: BridgeEventBatch) => void;
157
+ timer: NodeJS.Timeout;
158
+ }
159
+
160
+ class Bridge {
161
+ private readonly events: BridgeEvent[] = [];
162
+ private readonly parked = new Set<ParkedPoll>();
163
+ /** Unresolved dirty-both pairs, by document name. Writes are refused here. */
164
+ private readonly conflicts = new Map<string, BridgeConflict>();
165
+ /** Per-name task chain, so two conflicts for one pair never synth at once. */
166
+ private readonly chains = new Map<string, Promise<void>>();
167
+ private seq = 0;
168
+ private closed = false;
169
+
170
+ constructor(
171
+ readonly dir: string,
172
+ readonly assetsDir: string | undefined,
173
+ private readonly pollTimeoutMs: number,
174
+ private readonly watcher: FlowWatcher | undefined,
175
+ private readonly onEvent: ((event: BridgeEvent) => void) | undefined,
176
+ ) {
177
+ watcher?.on("synced", ({ name }) => this.enqueue(name, () => this.onSynced(name)));
178
+ watcher?.on("conflict", ({ name, reason }) =>
179
+ this.enqueue(name, () => this.onConflict(name, reason)),
180
+ );
181
+ watcher?.on("error", ({ path, message }) => {
182
+ this.publish({ kind: "error", name: docNameOf(path), path, message });
183
+ });
184
+ }
185
+
186
+ // -------------------------------------------------------------------------
187
+ // Events
188
+ // -------------------------------------------------------------------------
189
+
190
+ /** Adds an event and wakes every parked poll. */
191
+ publish(event: PendingEvent): BridgeEvent {
192
+ const full = { ...event, seq: ++this.seq } as BridgeEvent;
193
+ this.events.push(full);
194
+ if (this.events.length > EVENT_BUFFER) this.events.splice(0, this.events.length - EVENT_BUFFER);
195
+ this.onEvent?.(full);
196
+ for (const poll of [...this.parked]) this.answer(poll);
197
+ return full;
198
+ }
199
+
200
+ private since(cursor: number): BridgeEvent[] {
201
+ return this.events.filter((e) => e.seq > cursor);
202
+ }
203
+
204
+ private answer(poll: ParkedPoll): void {
205
+ this.parked.delete(poll);
206
+ clearTimeout(poll.timer);
207
+ const events = this.since(poll.cursor);
208
+ poll.respond({ cursor: events.at(-1)?.seq ?? poll.cursor, events });
209
+ }
210
+
211
+ poll(cursor: number, respond: (batch: BridgeEventBatch) => void): void {
212
+ const ready = this.since(cursor);
213
+ if (ready.length > 0 || this.closed) {
214
+ respond({ cursor: ready.at(-1)?.seq ?? cursor, events: ready });
215
+ return;
216
+ }
217
+ const poll: ParkedPoll = {
218
+ cursor,
219
+ respond,
220
+ timer: setTimeout(() => this.answer(poll), this.pollTimeoutMs),
221
+ };
222
+ // A parked poll must not keep the process alive on its own.
223
+ poll.timer.unref();
224
+ this.parked.add(poll);
225
+ }
226
+
227
+ /** Serializes work per document name, mirroring the watcher's own chain. */
228
+ private enqueue(name: string, work: () => Promise<void>): void {
229
+ const prev = this.chains.get(name) ?? Promise.resolve();
230
+ this.chains.set(
231
+ name,
232
+ prev.then(work).catch((err: unknown) => {
233
+ this.publish({
234
+ kind: "error",
235
+ name,
236
+ path: name,
237
+ message: err instanceof Error ? err.message : String(err),
238
+ });
239
+ }),
240
+ );
241
+ }
242
+
243
+ // -------------------------------------------------------------------------
244
+ // Watcher relay
245
+ // -------------------------------------------------------------------------
246
+
247
+ private async onSynced(name: string): Promise<void> {
248
+ // A pair that just synced is no longer in conflict, however it got there:
249
+ // the studio resolved it, or the user fixed the files by hand.
250
+ this.conflicts.delete(name);
251
+ const payload = await readPair(this.dir, name);
252
+ this.publish({ kind: "synced", ...payload });
253
+ }
254
+
255
+ private async onConflict(name: string, reason: string): Promise<void> {
256
+ await this.raiseConflict(name, reason);
257
+ }
258
+
259
+ /**
260
+ * Turns "both sides changed" into something a user can decide: the canvas
261
+ * side beside the FlowDoc the builder file synths to now. Neither file is
262
+ * touched, and the pair is frozen (writes answer 409) until it is resolved.
263
+ *
264
+ * `attempted` is the document a refused write was carrying. When it is
265
+ * given, the canvas side lives in the studio rather than on disk, which is
266
+ * what origin says and what decides how the choice is applied.
267
+ */
268
+ async raiseConflict(name: string, reason: string, attempted?: FlowDoc): Promise<BridgeConflict> {
269
+ const { docPath, tsPath } = pairPaths(this.dir, name);
270
+ const conflict: BridgeConflict = {
271
+ name,
272
+ reason,
273
+ origin: attempted === undefined ? "disk" : "canvas",
274
+ docPath,
275
+ tsPath,
276
+ docSide: attempted ?? null,
277
+ codeSide: null,
278
+ };
279
+ if (attempted === undefined) {
280
+ try {
281
+ conflict.docSide = (await readPair(this.dir, name)).doc;
282
+ } catch (err) {
283
+ conflict.docError = err instanceof Error ? err.message : String(err);
284
+ }
285
+ }
286
+ try {
287
+ conflict.codeSide = await synthPair(this.dir, name);
288
+ } catch (err) {
289
+ conflict.codeError = err instanceof Error ? err.message : String(err);
290
+ }
291
+ this.conflicts.set(name, conflict);
292
+ this.publish({ kind: "conflict", ...conflict });
293
+ return conflict;
294
+ }
295
+
296
+ conflictFor(name: string): BridgeConflict | undefined {
297
+ return this.conflicts.get(name);
298
+ }
299
+
300
+ clearConflict(name: string): void {
301
+ this.conflicts.delete(name);
302
+ }
303
+
304
+ async close(): Promise<void> {
305
+ this.closed = true;
306
+ for (const poll of [...this.parked]) this.answer(poll);
307
+ await this.watcher?.close();
308
+ await Promise.all([...this.chains.values()]);
309
+ }
310
+ }
311
+
312
+ /** The document name a route segment carries. A bad escape is a 400, not a 500. */
313
+ function decodeName(segment: string): string {
314
+ const name = decodePathSegment(segment);
315
+ if (name === undefined) {
316
+ throw new BridgeError(400, "That document name is not valid percent-encoding.");
317
+ }
318
+ return name;
319
+ }
320
+
321
+ /** The document name a watcher path belongs to, when it names one. */
322
+ function docNameOf(path: string): string | undefined {
323
+ const file = basename(path);
324
+ for (const suffix of [".flow.ts", ".flowdoc.json"]) {
325
+ if (file.endsWith(suffix)) {
326
+ const name = file.slice(0, -suffix.length);
327
+ return isBridgeDocName(name) ? name : undefined;
328
+ }
329
+ }
330
+ return undefined;
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // HTTP
335
+ // ---------------------------------------------------------------------------
336
+
337
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
338
+ const text = JSON.stringify(body);
339
+ res.writeHead(status, {
340
+ "content-type": "application/json; charset=utf-8",
341
+ "content-length": String(Buffer.byteLength(text)),
342
+ "cache-control": "no-store",
343
+ "x-content-type-options": "nosniff",
344
+ });
345
+ res.end(text);
346
+ }
347
+
348
+ function sendError(res: ServerResponse, status: number, message: string): void {
349
+ sendJson(res, status, { error: message });
350
+ }
351
+
352
+ /** Reads a JSON request body, refusing anything oversized or malformed. */
353
+ function readJsonBody(req: IncomingMessage): Promise<unknown> {
354
+ return new Promise((resolveP, rejectP) => {
355
+ const chunks: Buffer[] = [];
356
+ let size = 0;
357
+ let refused = false;
358
+ req.on("data", (chunk: Buffer) => {
359
+ size += chunk.length;
360
+ if (size > MAX_BODY_BYTES) {
361
+ // Past the cap nothing more is kept, so memory stays bounded whatever
362
+ // the client sends. The refusal waits for the end of the upload rather
363
+ // than interrupting it: answering mid-body and destroying the request
364
+ // takes the socket down with it, and the client sees a connection
365
+ // reset ("fetch failed") instead of the 413 this is trying to say.
366
+ refused = true;
367
+ chunks.length = 0;
368
+ return;
369
+ }
370
+ chunks.push(chunk);
371
+ });
372
+ req.on("error", rejectP);
373
+ req.on("end", () => {
374
+ if (refused) {
375
+ rejectP(new BridgeError(413, "Request body is too large."));
376
+ return;
377
+ }
378
+ const text = Buffer.concat(chunks).toString("utf8");
379
+ try {
380
+ resolveP(JSON.parse(text));
381
+ } catch (err) {
382
+ rejectP(
383
+ new BridgeError(400, `Body is not JSON: ${err instanceof Error ? err.message : "?"}`),
384
+ );
385
+ }
386
+ });
387
+ });
388
+ }
389
+
390
+ /** True when the Host header names this machine. Anti-DNS-rebinding. */
391
+ /** Case-insensitive single header value, or undefined. */
392
+ function header(req: IncomingMessage, name: string): string | undefined {
393
+ const v = req.headers[name];
394
+ return Array.isArray(v) ? v[0] : v;
395
+ }
396
+
397
+ /** Constant-time compare that does not leak length through early return. */
398
+ function safeEqual(a: string, b: string): boolean {
399
+ const ab = Buffer.from(a, "utf8");
400
+ const bb = Buffer.from(b, "utf8");
401
+ if (ab.length !== bb.length) return false;
402
+ return timingSafeEqual(ab, bb);
403
+ }
404
+
405
+ /** 256 bits from the CSPRNG. Never logged except as part of the URL. */
406
+ export function newSessionToken(): string {
407
+ return randomBytes(32).toString("base64url");
408
+ }
409
+
410
+ export function hostHeaderAllowed(host: string | undefined): boolean {
411
+ if (host === undefined) return false;
412
+ // Strip the port, keeping a bracketed IPv6 literal intact.
413
+ const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0];
414
+ return name !== undefined && ALLOWED_HOSTS.has(name);
415
+ }
416
+
417
+ /**
418
+ * Where an asset request points: the absolute path, or the status the refusal
419
+ * deserves. The two failures are not the same thing and must not answer the
420
+ * same way. A request the client mis-encoded is a 400 (it can be fixed by
421
+ * asking differently); a well-formed path that leaves the asset root is a 403
422
+ * (it cannot). Collapsing them was a real bug: decodeURIComponent throws a
423
+ * URIError on a truncated escape such as "/%E0%A4%A", which left this function
424
+ * uncaught and surfaced as 500 "URI malformed", reporting our own fault for
425
+ * the client's mistake.
426
+ */
427
+ export type AssetTarget =
428
+ { ok: true; path: string } | { ok: false; status: 400 | 403; message: string };
429
+
430
+ /**
431
+ * The URL parser has already collapsed "." and ".." segments, and the
432
+ * containment check below catches whatever survives that (an encoded
433
+ * separator, a symlinked name, a Windows drive letter).
434
+ */
435
+ export function assetPathFor(root: string, pathname: string): AssetTarget {
436
+ const decoded = decodePathSegment(pathname);
437
+ if (decoded === undefined) {
438
+ return { ok: false, status: 400, message: "That path is not valid percent-encoding." };
439
+ }
440
+ if (decoded.includes("\0")) {
441
+ return { ok: false, status: 400, message: "That path contains a NUL byte." };
442
+ }
443
+ const target = resolve(root, `.${decoded.startsWith("/") ? decoded : `/${decoded}`}`);
444
+ const base = resolve(root);
445
+ if (target !== base && !target.startsWith(base + sep)) {
446
+ return { ok: false, status: 403, message: "That path is outside the served directory." };
447
+ }
448
+ return { ok: true, path: target };
449
+ }
450
+
451
+ /**
452
+ * Percent-decodes one piece of a request path, or undefined when the escaping
453
+ * is malformed. Every decodeURIComponent in this file goes through here:
454
+ * unguarded it throws a URIError, and a URIError reaching the router is a 500
455
+ * for what is really a bad request.
456
+ */
457
+ export function decodePathSegment(raw: string): string | undefined {
458
+ try {
459
+ return decodeURIComponent(raw);
460
+ } catch {
461
+ return undefined;
462
+ }
463
+ }
464
+
465
+ class Router {
466
+ constructor(
467
+ private readonly bridge: Bridge,
468
+ private readonly info: BridgeInfo,
469
+ private readonly watcher: FlowWatcher | undefined,
470
+ ) {}
471
+
472
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
473
+ if (!hostHeaderAllowed(req.headers.host)) {
474
+ sendError(res, 403, "This bridge only answers requests addressed to localhost.");
475
+ return;
476
+ }
477
+ const url = new URL(req.url ?? "/", `http://${BRIDGE_HOST}`);
478
+ const method = req.method ?? "GET";
479
+
480
+ // Binding to 127.0.0.1 keeps the network out; it does NOT keep the
481
+ // developer's own browser out. Any page they visit while the studio runs
482
+ // can send a CORS-simple POST here, and this server writes files into a
483
+ // directory whose .flow.ts files `flow-cli synth` later executes. Refusing
484
+ // to send CORS headers only stops the attacker READING the reply, which is
485
+ // no defence at all against a write. See SECURITY.md.
486
+ const denial = this.rejectForgedRequest(req, url);
487
+ if (denial !== undefined) {
488
+ sendError(res, 403, denial);
489
+ return;
490
+ }
491
+
492
+ if (url.pathname === BRIDGE_PREFIX || url.pathname.startsWith(`${BRIDGE_PREFIX}/`)) {
493
+ try {
494
+ await this.api(method, url, req, res);
495
+ } catch (err) {
496
+ if (err instanceof BridgeError) sendError(res, err.status, err.message);
497
+ else sendError(res, 500, err instanceof Error ? err.message : String(err));
498
+ }
499
+ return;
500
+ }
501
+ await this.asset(method, url, res);
502
+ }
503
+
504
+ /**
505
+ * Why a request is refused, or undefined when it may proceed.
506
+ *
507
+ * Three independent checks, because each covers a case the others miss:
508
+ * - the session token, which a cross-origin page cannot read because it
509
+ * cannot read the URL of a document it did not open;
510
+ * - Sec-Fetch-Site, which browsers set and script cannot forge;
511
+ * - Origin, which is present on every cross-origin request that matters.
512
+ */
513
+ private rejectForgedRequest(req: IncomingMessage, url: URL): string | undefined {
514
+ const method = req.method ?? "GET";
515
+ const isApi = url.pathname === BRIDGE_PREFIX || url.pathname.startsWith(`${BRIDGE_PREFIX}/`);
516
+ const isDocument = url.pathname === "/" || url.pathname === "/index.html";
517
+
518
+ // A top-level navigation is how the studio gets opened: from the terminal
519
+ // (Sec-Fetch-Site: none), from a link in a web page, a chat client, or a
520
+ // browser extension (cross-site), or from a page on another local port
521
+ // (same-site). Refusing those turned the printed URL into a JSON error
522
+ // everywhere but the address bar. A navigation may proceed whatever its
523
+ // site: assets are static, and the page that opened the document cannot
524
+ // read it or script it. The document itself still needs the token, below.
525
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Dest
526
+ const isNavigation =
527
+ !isApi &&
528
+ (method === "GET" || method === "HEAD") &&
529
+ header(req, "sec-fetch-mode") === "navigate" &&
530
+ header(req, "sec-fetch-dest") === "document";
531
+
532
+ const site = header(req, "sec-fetch-site");
533
+ if (site !== undefined && site !== "same-origin" && site !== "none" && !isNavigation) {
534
+ return "This bridge does not answer cross-site requests.";
535
+ }
536
+
537
+ const origin = header(req, "origin");
538
+ if (origin !== undefined && !this.isOwnOrigin(origin)) {
539
+ return "This bridge does not answer requests from another origin.";
540
+ }
541
+
542
+ // Subresources are readable without a token so the token never has to
543
+ // appear in an asset URL. The document is not: index.html carries the
544
+ // bridge description, token included, in its boot script (injectBoot), so
545
+ // serving it to a bare navigation would hand the token to any page that
546
+ // opened a tab on this port. Neither is the bridge API.
547
+ if (!isApi && !isDocument) return undefined;
548
+
549
+ const presented = url.searchParams.get(TOKEN_PARAM) ?? header(req, TOKEN_HEADER);
550
+ if (presented === undefined || presented === null || !safeEqual(presented, this.info.token)) {
551
+ return "Missing or invalid bridge token. Open the URL flow-cli printed.";
552
+ }
553
+ return undefined;
554
+ }
555
+
556
+ private isOwnOrigin(origin: string): boolean {
557
+ try {
558
+ const host = new URL(origin).hostname;
559
+ return ALLOWED_HOSTS.has(host);
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+
565
+ private async api(
566
+ method: string,
567
+ url: URL,
568
+ req: IncomingMessage,
569
+ res: ServerResponse,
570
+ ): Promise<void> {
571
+ const route = url.pathname.slice(BRIDGE_PREFIX.length);
572
+
573
+ if (route === "/info" && method === "GET") {
574
+ sendJson(res, 200, this.info);
575
+ return;
576
+ }
577
+
578
+ if (route === "/events" && method === "GET") {
579
+ const cursor = Number(url.searchParams.get("cursor") ?? "0");
580
+ this.bridge.poll(Number.isFinite(cursor) && cursor > 0 ? cursor : 0, (batch) => {
581
+ if (!res.writableEnded) sendJson(res, 200, batch);
582
+ });
583
+ return;
584
+ }
585
+
586
+ if (route === "/export" && method === "POST") {
587
+ // The studio emitted the files; this writes them. See ./exportFiles.ts.
588
+ const request = checkExportRequest(await readJsonBody(req));
589
+ sendJson(res, 200, await writeExport(this.bridge.dir, request));
590
+ return;
591
+ }
592
+
593
+ if (route === "/docs" && method === "GET") {
594
+ sendJson(res, 200, { docs: (await listDocNames(this.bridge.dir)).map((name) => ({ name })) });
595
+ return;
596
+ }
597
+
598
+ const docMatch = /^\/docs\/([^/]+)$/.exec(route);
599
+ if (docMatch !== null) {
600
+ const name = decodeName(docMatch[1]!);
601
+ if (method === "GET") {
602
+ sendJson(res, 200, await readPair(this.bridge.dir, name));
603
+ return;
604
+ }
605
+ if (method === "PUT") {
606
+ await this.write(name, req, res);
607
+ return;
608
+ }
609
+ }
610
+
611
+ const resolveMatch = /^\/docs\/([^/]+)\/resolve$/.exec(route);
612
+ if (resolveMatch !== null && method === "POST") {
613
+ await this.resolveConflict(decodeName(resolveMatch[1]!), req, res);
614
+ return;
615
+ }
616
+
617
+ sendError(res, 404, `No bridge route for ${method} ${url.pathname}.`);
618
+ }
619
+
620
+ private async write(name: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
621
+ const body = (await readJsonBody(req)) as BridgeWriteRequest;
622
+ if (body === null || typeof body !== "object" || typeof body.doc !== "object") {
623
+ throw new BridgeError(400, 'The body must be a JSON object with a "doc" property.');
624
+ }
625
+ const conflict = this.bridge.conflictFor(name);
626
+ if (conflict !== undefined && body.force !== true) {
627
+ // Never merge silently: until the user picks a side, this pair is frozen.
628
+ // `force` IS the user picking a side, so it is the one write that passes.
629
+ sendJson(res, 409, {
630
+ error:
631
+ `"${name}" changed on both sides since the last sync. Choose which version wins ` +
632
+ `before saving again.`,
633
+ conflict,
634
+ });
635
+ return;
636
+ }
637
+ let written;
638
+ try {
639
+ written = await writePair(this.bridge.dir, name, body.doc, this.watcher, {
640
+ force: body.force === true,
641
+ });
642
+ } catch (err) {
643
+ if (err instanceof PairConflict) {
644
+ // The builder file moved under this document. Freeze the pair and ask.
645
+ const raised = await this.bridge.raiseConflict(name, err.reason, body.doc);
646
+ sendJson(res, 409, {
647
+ error: `Refusing to overwrite ${name}${TS_SUFFIX}: ${err.reason}.`,
648
+ conflict: raised,
649
+ });
650
+ return;
651
+ }
652
+ throw err;
653
+ }
654
+ // A write that got through leaves the pair in sync, so it is no longer in
655
+ // conflict however it got there (a forced write is the user's answer).
656
+ this.bridge.clearConflict(name);
657
+ // Other viewers (a second tab) learn about the save the same way they
658
+ // learn about a builder-file edit.
659
+ this.bridge.publish({
660
+ kind: "synced",
661
+ name: written.name,
662
+ doc: written.doc,
663
+ text: written.text,
664
+ });
665
+ sendJson(res, 200, written);
666
+ }
667
+
668
+ private async resolveConflict(
669
+ name: string,
670
+ req: IncomingMessage,
671
+ res: ServerResponse,
672
+ ): Promise<void> {
673
+ const body = (await readJsonBody(req)) as BridgeResolveRequest;
674
+ if (
675
+ body === null ||
676
+ typeof body !== "object" ||
677
+ (body.side !== "doc" && body.side !== "code")
678
+ ) {
679
+ throw new BridgeError(400, 'The body must be {"side":"doc"} or {"side":"code"}.');
680
+ }
681
+ const payload =
682
+ body.side === "code"
683
+ ? await adoptCode(this.bridge.dir, name, this.watcher)
684
+ : await this.adoptDoc(name);
685
+ this.bridge.clearConflict(name);
686
+ this.bridge.publish({ kind: "synced", ...payload });
687
+ sendJson(res, 200, payload);
688
+ }
689
+
690
+ /** The FlowDoc wins: regenerate the builder source from it. */
691
+ private async adoptDoc(name: string): Promise<BridgeDocPayload> {
692
+ const current = await readPair(this.bridge.dir, name);
693
+ const written = await writePair(this.bridge.dir, name, current.doc, this.watcher);
694
+ return { name: written.name, doc: written.doc, text: written.text };
695
+ }
696
+
697
+ private async asset(method: string, url: URL, res: ServerResponse): Promise<void> {
698
+ const root = this.bridge.assetsDir;
699
+ if (root === undefined) {
700
+ sendError(res, 404, "This bridge serves the API only; the studio assets are not mounted.");
701
+ return;
702
+ }
703
+ if (method !== "GET" && method !== "HEAD") {
704
+ sendError(res, 405, `${method} is not allowed for assets.`);
705
+ return;
706
+ }
707
+ const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
708
+ const target = assetPathFor(root, pathname);
709
+ if (!target.ok) {
710
+ sendError(res, target.status, target.message);
711
+ return;
712
+ }
713
+ const file = target.path;
714
+ let body: Buffer;
715
+ try {
716
+ if (!(await stat(file)).isFile()) throw new Error("not a file");
717
+ body = await readFile(file);
718
+ } catch {
719
+ sendError(res, 404, `Not found: ${url.pathname}`);
720
+ return;
721
+ }
722
+ // index.html carries the bridge's own description, so the studio knows at
723
+ // boot that it is served by a bridge without probing for one.
724
+ if (basename(file) === "index.html") {
725
+ body = Buffer.from(injectBoot(body.toString("utf8"), this.info), "utf8");
726
+ }
727
+ res.writeHead(200, {
728
+ "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
729
+ "content-length": String(body.length),
730
+ "cache-control": "no-store",
731
+ "x-content-type-options": "nosniff",
732
+ });
733
+ res.end(method === "HEAD" ? undefined : body);
734
+ }
735
+ }
736
+
737
+ /** Puts the boot script first in <head>, before the app's module script. */
738
+ export function injectBoot(html: string, info: BridgeInfo): string {
739
+ const script = bridgeBootScript(info);
740
+ const head = html.indexOf("<head>");
741
+ if (head < 0) return `${script}${html}`;
742
+ return html.slice(0, head + 6) + script + html.slice(head + 6);
743
+ }
744
+
745
+ /**
746
+ * Starts the bridge on 127.0.0.1. Resolves once it is listening, with the URL
747
+ * to open.
748
+ */
749
+ export async function startStudioServer(options: StudioServerOptions): Promise<StudioServer> {
750
+ const dir = resolve(options.dir);
751
+ try {
752
+ if (!(await stat(dir)).isDirectory()) {
753
+ throw new BridgeError(400, `${dir} is not a directory.`);
754
+ }
755
+ } catch (err) {
756
+ if (err instanceof BridgeError) throw err;
757
+ throw new BridgeError(400, `No such directory: ${dir}`);
758
+ }
759
+
760
+ // Before the watcher exists, so its initial scan sees complete pairs rather
761
+ // than a document whose builder file appears underneath it.
762
+ const prepared: EnsureBuilderFilesResult =
763
+ options.ensurePairs === true ? await ensureBuilderFiles(dir) : { generated: [], problems: [] };
764
+
765
+ const watcher = options.watch === false ? undefined : createWatcher(dir);
766
+ // Seed the ledger with the exact bytes just written. The meta.sourceHash
767
+ // stamp would let the watcher recognize the pair as in sync on its own, but
768
+ // only if the initial scan reaches the file before the user's first edit
769
+ // does; telling it outright removes the race.
770
+ for (const { name, tsText, docText } of prepared.generated) {
771
+ watcher?.noteWrite(name, { tsContent: tsText, docContent: docText });
772
+ }
773
+ const bridge = new Bridge(
774
+ dir,
775
+ options.assetsDir === undefined ? undefined : resolve(options.assetsDir),
776
+ options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS,
777
+ watcher,
778
+ options.onEvent,
779
+ );
780
+ const info: BridgeInfo = {
781
+ protocol: BRIDGE_PROTOCOL,
782
+ dir,
783
+ label: basename(dir),
784
+ token: options.token ?? newSessionToken(),
785
+ };
786
+ const router = new Router(bridge, info, watcher);
787
+
788
+ const server = createServer((req, res) => {
789
+ void router.handle(req, res).catch(() => {
790
+ if (!res.headersSent) sendError(res, 500, "Internal bridge error.");
791
+ else res.end();
792
+ });
793
+ });
794
+
795
+ await new Promise<void>((resolveP, rejectP) => {
796
+ server.once("error", rejectP);
797
+ // The host is fixed: see the note at the top of this file.
798
+ server.listen({ host: BRIDGE_HOST, port: options.port ?? 0 }, () => {
799
+ server.removeListener("error", rejectP);
800
+ resolveP();
801
+ });
802
+ });
803
+
804
+ const address = server.address();
805
+ const port = typeof address === "object" && address !== null ? address.port : 0;
806
+
807
+ return {
808
+ // The token rides in the URL, which is what makes the page the developer
809
+ // opens distinguishable from every other tab in their browser.
810
+ url: `http://${BRIDGE_HOST}:${String(port)}/?${TOKEN_PARAM}=${info.token}`,
811
+ host: BRIDGE_HOST,
812
+ port,
813
+ token: info.token,
814
+ dir,
815
+ prepared,
816
+ server,
817
+ async close() {
818
+ // Parked polls are open connections, so they are answered before the
819
+ // server is asked to stop; otherwise close() waits for the poll timeout.
820
+ await bridge.close();
821
+ const stopped = new Promise<void>((resolveP) => server.close(() => resolveP()));
822
+ server.closeIdleConnections();
823
+ server.closeAllConnections();
824
+ await stopped;
825
+ },
826
+ };
827
+ }