@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 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/bridge/server.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAsC,KAAK,MAAM,EAAuB,MAAM,WAAW,CAAC;AAQjG,OAAO,EASL,KAAK,WAAW,EAEhB,KAAK,UAAU,EAIhB,MAAM,eAAe,CAAC;AAEvB,OAAO,EAUL,KAAK,wBAAwB,EAC9B,MAAM,WAAW,CAAC;AAEnB;;;;GAIG;AACH,KAAK,WAAW,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,KAAK,CAAC;AACzE,MAAM,MAAM,YAAY,GAAG,WAAW,CAAC,WAAW,CAAC,CAAC;AAEpD,iFAAiF;AACjF,eAAO,MAAM,WAAW,cAAc,CAAC;AA+BvC,MAAM,WAAW,mBAAmB;IAClC,wDAAwD;IACxD,GAAG,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uEAAuE;IACvE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,wBAAwB,CAAC;IAC5C,sEAAsE;IACtE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AA6PD,wEAAwE;AACxE,wBAAgB,eAAe,IAAI,MAAM,CAExC;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAKnE;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,WAAW,GACrB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjF;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,WAAW,CAcxE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMjE;AAkRD,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAKjE;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,YAAY,CAAC,CA8E3F"}
@@ -0,0 +1,669 @@
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
+ import { createServer } from "node:http";
36
+ import { randomBytes, timingSafeEqual } from "node:crypto";
37
+ import { readFile, stat } from "node:fs/promises";
38
+ import { basename, extname, resolve, sep } from "node:path";
39
+ import { createWatcher } from "../watch.js";
40
+ import { BRIDGE_PREFIX, TOKEN_HEADER, TOKEN_PARAM, BRIDGE_PROTOCOL, TS_SUFFIX, bridgeBootScript, isBridgeDocName, } from "./protocol.js";
41
+ import { checkExportRequest, writeExport } from "./exportFiles.js";
42
+ import { BridgeError, PairConflict, adoptCode, ensureBuilderFiles, listDocNames, pairPaths, readPair, synthPair, writePair, } from "./pair.js";
43
+ /** The one address this server may bind. See the note at the top of the file. */
44
+ export const BRIDGE_HOST = "127.0.0.1";
45
+ /** Hosts the Host header may name, port aside. */
46
+ const ALLOWED_HOSTS = new Set([BRIDGE_HOST, "localhost", "[::1]", "::1"]);
47
+ /** A parked event poll answers with an empty batch after this long. */
48
+ const DEFAULT_POLL_TIMEOUT_MS = 25_000;
49
+ /** Bodies larger than this are refused unread. */
50
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
51
+ /** Events kept for clients that are between polls. */
52
+ const EVENT_BUFFER = 500;
53
+ const MIME = {
54
+ ".html": "text/html; charset=utf-8",
55
+ ".js": "text/javascript; charset=utf-8",
56
+ ".mjs": "text/javascript; charset=utf-8",
57
+ ".css": "text/css; charset=utf-8",
58
+ ".json": "application/json; charset=utf-8",
59
+ ".map": "application/json; charset=utf-8",
60
+ ".svg": "image/svg+xml",
61
+ ".png": "image/png",
62
+ ".jpg": "image/jpeg",
63
+ ".webp": "image/webp",
64
+ ".ico": "image/x-icon",
65
+ ".woff": "font/woff",
66
+ ".woff2": "font/woff2",
67
+ ".txt": "text/plain; charset=utf-8",
68
+ };
69
+ class Bridge {
70
+ dir;
71
+ assetsDir;
72
+ pollTimeoutMs;
73
+ watcher;
74
+ onEvent;
75
+ events = [];
76
+ parked = new Set();
77
+ /** Unresolved dirty-both pairs, by document name. Writes are refused here. */
78
+ conflicts = new Map();
79
+ /** Per-name task chain, so two conflicts for one pair never synth at once. */
80
+ chains = new Map();
81
+ seq = 0;
82
+ closed = false;
83
+ constructor(dir, assetsDir, pollTimeoutMs, watcher, onEvent) {
84
+ this.dir = dir;
85
+ this.assetsDir = assetsDir;
86
+ this.pollTimeoutMs = pollTimeoutMs;
87
+ this.watcher = watcher;
88
+ this.onEvent = onEvent;
89
+ watcher?.on("synced", ({ name }) => this.enqueue(name, () => this.onSynced(name)));
90
+ watcher?.on("conflict", ({ name, reason }) => this.enqueue(name, () => this.onConflict(name, reason)));
91
+ watcher?.on("error", ({ path, message }) => {
92
+ this.publish({ kind: "error", name: docNameOf(path), path, message });
93
+ });
94
+ }
95
+ // -------------------------------------------------------------------------
96
+ // Events
97
+ // -------------------------------------------------------------------------
98
+ /** Adds an event and wakes every parked poll. */
99
+ publish(event) {
100
+ const full = { ...event, seq: ++this.seq };
101
+ this.events.push(full);
102
+ if (this.events.length > EVENT_BUFFER)
103
+ this.events.splice(0, this.events.length - EVENT_BUFFER);
104
+ this.onEvent?.(full);
105
+ for (const poll of [...this.parked])
106
+ this.answer(poll);
107
+ return full;
108
+ }
109
+ since(cursor) {
110
+ return this.events.filter((e) => e.seq > cursor);
111
+ }
112
+ answer(poll) {
113
+ this.parked.delete(poll);
114
+ clearTimeout(poll.timer);
115
+ const events = this.since(poll.cursor);
116
+ poll.respond({ cursor: events.at(-1)?.seq ?? poll.cursor, events });
117
+ }
118
+ poll(cursor, respond) {
119
+ const ready = this.since(cursor);
120
+ if (ready.length > 0 || this.closed) {
121
+ respond({ cursor: ready.at(-1)?.seq ?? cursor, events: ready });
122
+ return;
123
+ }
124
+ const poll = {
125
+ cursor,
126
+ respond,
127
+ timer: setTimeout(() => this.answer(poll), this.pollTimeoutMs),
128
+ };
129
+ // A parked poll must not keep the process alive on its own.
130
+ poll.timer.unref();
131
+ this.parked.add(poll);
132
+ }
133
+ /** Serializes work per document name, mirroring the watcher's own chain. */
134
+ enqueue(name, work) {
135
+ const prev = this.chains.get(name) ?? Promise.resolve();
136
+ this.chains.set(name, prev.then(work).catch((err) => {
137
+ this.publish({
138
+ kind: "error",
139
+ name,
140
+ path: name,
141
+ message: err instanceof Error ? err.message : String(err),
142
+ });
143
+ }));
144
+ }
145
+ // -------------------------------------------------------------------------
146
+ // Watcher relay
147
+ // -------------------------------------------------------------------------
148
+ async onSynced(name) {
149
+ // A pair that just synced is no longer in conflict, however it got there:
150
+ // the studio resolved it, or the user fixed the files by hand.
151
+ this.conflicts.delete(name);
152
+ const payload = await readPair(this.dir, name);
153
+ this.publish({ kind: "synced", ...payload });
154
+ }
155
+ async onConflict(name, reason) {
156
+ await this.raiseConflict(name, reason);
157
+ }
158
+ /**
159
+ * Turns "both sides changed" into something a user can decide: the canvas
160
+ * side beside the FlowDoc the builder file synths to now. Neither file is
161
+ * touched, and the pair is frozen (writes answer 409) until it is resolved.
162
+ *
163
+ * `attempted` is the document a refused write was carrying. When it is
164
+ * given, the canvas side lives in the studio rather than on disk, which is
165
+ * what origin says and what decides how the choice is applied.
166
+ */
167
+ async raiseConflict(name, reason, attempted) {
168
+ const { docPath, tsPath } = pairPaths(this.dir, name);
169
+ const conflict = {
170
+ name,
171
+ reason,
172
+ origin: attempted === undefined ? "disk" : "canvas",
173
+ docPath,
174
+ tsPath,
175
+ docSide: attempted ?? null,
176
+ codeSide: null,
177
+ };
178
+ if (attempted === undefined) {
179
+ try {
180
+ conflict.docSide = (await readPair(this.dir, name)).doc;
181
+ }
182
+ catch (err) {
183
+ conflict.docError = err instanceof Error ? err.message : String(err);
184
+ }
185
+ }
186
+ try {
187
+ conflict.codeSide = await synthPair(this.dir, name);
188
+ }
189
+ catch (err) {
190
+ conflict.codeError = err instanceof Error ? err.message : String(err);
191
+ }
192
+ this.conflicts.set(name, conflict);
193
+ this.publish({ kind: "conflict", ...conflict });
194
+ return conflict;
195
+ }
196
+ conflictFor(name) {
197
+ return this.conflicts.get(name);
198
+ }
199
+ clearConflict(name) {
200
+ this.conflicts.delete(name);
201
+ }
202
+ async close() {
203
+ this.closed = true;
204
+ for (const poll of [...this.parked])
205
+ this.answer(poll);
206
+ await this.watcher?.close();
207
+ await Promise.all([...this.chains.values()]);
208
+ }
209
+ }
210
+ /** The document name a route segment carries. A bad escape is a 400, not a 500. */
211
+ function decodeName(segment) {
212
+ const name = decodePathSegment(segment);
213
+ if (name === undefined) {
214
+ throw new BridgeError(400, "That document name is not valid percent-encoding.");
215
+ }
216
+ return name;
217
+ }
218
+ /** The document name a watcher path belongs to, when it names one. */
219
+ function docNameOf(path) {
220
+ const file = basename(path);
221
+ for (const suffix of [".flow.ts", ".flowdoc.json"]) {
222
+ if (file.endsWith(suffix)) {
223
+ const name = file.slice(0, -suffix.length);
224
+ return isBridgeDocName(name) ? name : undefined;
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // HTTP
231
+ // ---------------------------------------------------------------------------
232
+ function sendJson(res, status, body) {
233
+ const text = JSON.stringify(body);
234
+ res.writeHead(status, {
235
+ "content-type": "application/json; charset=utf-8",
236
+ "content-length": String(Buffer.byteLength(text)),
237
+ "cache-control": "no-store",
238
+ "x-content-type-options": "nosniff",
239
+ });
240
+ res.end(text);
241
+ }
242
+ function sendError(res, status, message) {
243
+ sendJson(res, status, { error: message });
244
+ }
245
+ /** Reads a JSON request body, refusing anything oversized or malformed. */
246
+ function readJsonBody(req) {
247
+ return new Promise((resolveP, rejectP) => {
248
+ const chunks = [];
249
+ let size = 0;
250
+ let refused = false;
251
+ req.on("data", (chunk) => {
252
+ size += chunk.length;
253
+ if (size > MAX_BODY_BYTES) {
254
+ // Past the cap nothing more is kept, so memory stays bounded whatever
255
+ // the client sends. The refusal waits for the end of the upload rather
256
+ // than interrupting it: answering mid-body and destroying the request
257
+ // takes the socket down with it, and the client sees a connection
258
+ // reset ("fetch failed") instead of the 413 this is trying to say.
259
+ refused = true;
260
+ chunks.length = 0;
261
+ return;
262
+ }
263
+ chunks.push(chunk);
264
+ });
265
+ req.on("error", rejectP);
266
+ req.on("end", () => {
267
+ if (refused) {
268
+ rejectP(new BridgeError(413, "Request body is too large."));
269
+ return;
270
+ }
271
+ const text = Buffer.concat(chunks).toString("utf8");
272
+ try {
273
+ resolveP(JSON.parse(text));
274
+ }
275
+ catch (err) {
276
+ rejectP(new BridgeError(400, `Body is not JSON: ${err instanceof Error ? err.message : "?"}`));
277
+ }
278
+ });
279
+ });
280
+ }
281
+ /** True when the Host header names this machine. Anti-DNS-rebinding. */
282
+ /** Case-insensitive single header value, or undefined. */
283
+ function header(req, name) {
284
+ const v = req.headers[name];
285
+ return Array.isArray(v) ? v[0] : v;
286
+ }
287
+ /** Constant-time compare that does not leak length through early return. */
288
+ function safeEqual(a, b) {
289
+ const ab = Buffer.from(a, "utf8");
290
+ const bb = Buffer.from(b, "utf8");
291
+ if (ab.length !== bb.length)
292
+ return false;
293
+ return timingSafeEqual(ab, bb);
294
+ }
295
+ /** 256 bits from the CSPRNG. Never logged except as part of the URL. */
296
+ export function newSessionToken() {
297
+ return randomBytes(32).toString("base64url");
298
+ }
299
+ export function hostHeaderAllowed(host) {
300
+ if (host === undefined)
301
+ return false;
302
+ // Strip the port, keeping a bracketed IPv6 literal intact.
303
+ const name = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0];
304
+ return name !== undefined && ALLOWED_HOSTS.has(name);
305
+ }
306
+ /**
307
+ * The URL parser has already collapsed "." and ".." segments, and the
308
+ * containment check below catches whatever survives that (an encoded
309
+ * separator, a symlinked name, a Windows drive letter).
310
+ */
311
+ export function assetPathFor(root, pathname) {
312
+ const decoded = decodePathSegment(pathname);
313
+ if (decoded === undefined) {
314
+ return { ok: false, status: 400, message: "That path is not valid percent-encoding." };
315
+ }
316
+ if (decoded.includes("\0")) {
317
+ return { ok: false, status: 400, message: "That path contains a NUL byte." };
318
+ }
319
+ const target = resolve(root, `.${decoded.startsWith("/") ? decoded : `/${decoded}`}`);
320
+ const base = resolve(root);
321
+ if (target !== base && !target.startsWith(base + sep)) {
322
+ return { ok: false, status: 403, message: "That path is outside the served directory." };
323
+ }
324
+ return { ok: true, path: target };
325
+ }
326
+ /**
327
+ * Percent-decodes one piece of a request path, or undefined when the escaping
328
+ * is malformed. Every decodeURIComponent in this file goes through here:
329
+ * unguarded it throws a URIError, and a URIError reaching the router is a 500
330
+ * for what is really a bad request.
331
+ */
332
+ export function decodePathSegment(raw) {
333
+ try {
334
+ return decodeURIComponent(raw);
335
+ }
336
+ catch {
337
+ return undefined;
338
+ }
339
+ }
340
+ class Router {
341
+ bridge;
342
+ info;
343
+ watcher;
344
+ constructor(bridge, info, watcher) {
345
+ this.bridge = bridge;
346
+ this.info = info;
347
+ this.watcher = watcher;
348
+ }
349
+ async handle(req, res) {
350
+ if (!hostHeaderAllowed(req.headers.host)) {
351
+ sendError(res, 403, "This bridge only answers requests addressed to localhost.");
352
+ return;
353
+ }
354
+ const url = new URL(req.url ?? "/", `http://${BRIDGE_HOST}`);
355
+ const method = req.method ?? "GET";
356
+ // Binding to 127.0.0.1 keeps the network out; it does NOT keep the
357
+ // developer's own browser out. Any page they visit while the studio runs
358
+ // can send a CORS-simple POST here, and this server writes files into a
359
+ // directory whose .flow.ts files `flow-cli synth` later executes. Refusing
360
+ // to send CORS headers only stops the attacker READING the reply, which is
361
+ // no defence at all against a write. See SECURITY.md.
362
+ const denial = this.rejectForgedRequest(req, url);
363
+ if (denial !== undefined) {
364
+ sendError(res, 403, denial);
365
+ return;
366
+ }
367
+ if (url.pathname === BRIDGE_PREFIX || url.pathname.startsWith(`${BRIDGE_PREFIX}/`)) {
368
+ try {
369
+ await this.api(method, url, req, res);
370
+ }
371
+ catch (err) {
372
+ if (err instanceof BridgeError)
373
+ sendError(res, err.status, err.message);
374
+ else
375
+ sendError(res, 500, err instanceof Error ? err.message : String(err));
376
+ }
377
+ return;
378
+ }
379
+ await this.asset(method, url, res);
380
+ }
381
+ /**
382
+ * Why a request is refused, or undefined when it may proceed.
383
+ *
384
+ * Three independent checks, because each covers a case the others miss:
385
+ * - the session token, which a cross-origin page cannot read because it
386
+ * cannot read the URL of a document it did not open;
387
+ * - Sec-Fetch-Site, which browsers set and script cannot forge;
388
+ * - Origin, which is present on every cross-origin request that matters.
389
+ */
390
+ rejectForgedRequest(req, url) {
391
+ const method = req.method ?? "GET";
392
+ const isApi = url.pathname === BRIDGE_PREFIX || url.pathname.startsWith(`${BRIDGE_PREFIX}/`);
393
+ const isDocument = url.pathname === "/" || url.pathname === "/index.html";
394
+ // A top-level navigation is how the studio gets opened: from the terminal
395
+ // (Sec-Fetch-Site: none), from a link in a web page, a chat client, or a
396
+ // browser extension (cross-site), or from a page on another local port
397
+ // (same-site). Refusing those turned the printed URL into a JSON error
398
+ // everywhere but the address bar. A navigation may proceed whatever its
399
+ // site: assets are static, and the page that opened the document cannot
400
+ // read it or script it. The document itself still needs the token, below.
401
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Dest
402
+ const isNavigation = !isApi &&
403
+ (method === "GET" || method === "HEAD") &&
404
+ header(req, "sec-fetch-mode") === "navigate" &&
405
+ header(req, "sec-fetch-dest") === "document";
406
+ const site = header(req, "sec-fetch-site");
407
+ if (site !== undefined && site !== "same-origin" && site !== "none" && !isNavigation) {
408
+ return "This bridge does not answer cross-site requests.";
409
+ }
410
+ const origin = header(req, "origin");
411
+ if (origin !== undefined && !this.isOwnOrigin(origin)) {
412
+ return "This bridge does not answer requests from another origin.";
413
+ }
414
+ // Subresources are readable without a token so the token never has to
415
+ // appear in an asset URL. The document is not: index.html carries the
416
+ // bridge description, token included, in its boot script (injectBoot), so
417
+ // serving it to a bare navigation would hand the token to any page that
418
+ // opened a tab on this port. Neither is the bridge API.
419
+ if (!isApi && !isDocument)
420
+ return undefined;
421
+ const presented = url.searchParams.get(TOKEN_PARAM) ?? header(req, TOKEN_HEADER);
422
+ if (presented === undefined || presented === null || !safeEqual(presented, this.info.token)) {
423
+ return "Missing or invalid bridge token. Open the URL flow-cli printed.";
424
+ }
425
+ return undefined;
426
+ }
427
+ isOwnOrigin(origin) {
428
+ try {
429
+ const host = new URL(origin).hostname;
430
+ return ALLOWED_HOSTS.has(host);
431
+ }
432
+ catch {
433
+ return false;
434
+ }
435
+ }
436
+ async api(method, url, req, res) {
437
+ const route = url.pathname.slice(BRIDGE_PREFIX.length);
438
+ if (route === "/info" && method === "GET") {
439
+ sendJson(res, 200, this.info);
440
+ return;
441
+ }
442
+ if (route === "/events" && method === "GET") {
443
+ const cursor = Number(url.searchParams.get("cursor") ?? "0");
444
+ this.bridge.poll(Number.isFinite(cursor) && cursor > 0 ? cursor : 0, (batch) => {
445
+ if (!res.writableEnded)
446
+ sendJson(res, 200, batch);
447
+ });
448
+ return;
449
+ }
450
+ if (route === "/export" && method === "POST") {
451
+ // The studio emitted the files; this writes them. See ./exportFiles.ts.
452
+ const request = checkExportRequest(await readJsonBody(req));
453
+ sendJson(res, 200, await writeExport(this.bridge.dir, request));
454
+ return;
455
+ }
456
+ if (route === "/docs" && method === "GET") {
457
+ sendJson(res, 200, { docs: (await listDocNames(this.bridge.dir)).map((name) => ({ name })) });
458
+ return;
459
+ }
460
+ const docMatch = /^\/docs\/([^/]+)$/.exec(route);
461
+ if (docMatch !== null) {
462
+ const name = decodeName(docMatch[1]);
463
+ if (method === "GET") {
464
+ sendJson(res, 200, await readPair(this.bridge.dir, name));
465
+ return;
466
+ }
467
+ if (method === "PUT") {
468
+ await this.write(name, req, res);
469
+ return;
470
+ }
471
+ }
472
+ const resolveMatch = /^\/docs\/([^/]+)\/resolve$/.exec(route);
473
+ if (resolveMatch !== null && method === "POST") {
474
+ await this.resolveConflict(decodeName(resolveMatch[1]), req, res);
475
+ return;
476
+ }
477
+ sendError(res, 404, `No bridge route for ${method} ${url.pathname}.`);
478
+ }
479
+ async write(name, req, res) {
480
+ const body = (await readJsonBody(req));
481
+ if (body === null || typeof body !== "object" || typeof body.doc !== "object") {
482
+ throw new BridgeError(400, 'The body must be a JSON object with a "doc" property.');
483
+ }
484
+ const conflict = this.bridge.conflictFor(name);
485
+ if (conflict !== undefined && body.force !== true) {
486
+ // Never merge silently: until the user picks a side, this pair is frozen.
487
+ // `force` IS the user picking a side, so it is the one write that passes.
488
+ sendJson(res, 409, {
489
+ error: `"${name}" changed on both sides since the last sync. Choose which version wins ` +
490
+ `before saving again.`,
491
+ conflict,
492
+ });
493
+ return;
494
+ }
495
+ let written;
496
+ try {
497
+ written = await writePair(this.bridge.dir, name, body.doc, this.watcher, {
498
+ force: body.force === true,
499
+ });
500
+ }
501
+ catch (err) {
502
+ if (err instanceof PairConflict) {
503
+ // The builder file moved under this document. Freeze the pair and ask.
504
+ const raised = await this.bridge.raiseConflict(name, err.reason, body.doc);
505
+ sendJson(res, 409, {
506
+ error: `Refusing to overwrite ${name}${TS_SUFFIX}: ${err.reason}.`,
507
+ conflict: raised,
508
+ });
509
+ return;
510
+ }
511
+ throw err;
512
+ }
513
+ // A write that got through leaves the pair in sync, so it is no longer in
514
+ // conflict however it got there (a forced write is the user's answer).
515
+ this.bridge.clearConflict(name);
516
+ // Other viewers (a second tab) learn about the save the same way they
517
+ // learn about a builder-file edit.
518
+ this.bridge.publish({
519
+ kind: "synced",
520
+ name: written.name,
521
+ doc: written.doc,
522
+ text: written.text,
523
+ });
524
+ sendJson(res, 200, written);
525
+ }
526
+ async resolveConflict(name, req, res) {
527
+ const body = (await readJsonBody(req));
528
+ if (body === null ||
529
+ typeof body !== "object" ||
530
+ (body.side !== "doc" && body.side !== "code")) {
531
+ throw new BridgeError(400, 'The body must be {"side":"doc"} or {"side":"code"}.');
532
+ }
533
+ const payload = body.side === "code"
534
+ ? await adoptCode(this.bridge.dir, name, this.watcher)
535
+ : await this.adoptDoc(name);
536
+ this.bridge.clearConflict(name);
537
+ this.bridge.publish({ kind: "synced", ...payload });
538
+ sendJson(res, 200, payload);
539
+ }
540
+ /** The FlowDoc wins: regenerate the builder source from it. */
541
+ async adoptDoc(name) {
542
+ const current = await readPair(this.bridge.dir, name);
543
+ const written = await writePair(this.bridge.dir, name, current.doc, this.watcher);
544
+ return { name: written.name, doc: written.doc, text: written.text };
545
+ }
546
+ async asset(method, url, res) {
547
+ const root = this.bridge.assetsDir;
548
+ if (root === undefined) {
549
+ sendError(res, 404, "This bridge serves the API only; the studio assets are not mounted.");
550
+ return;
551
+ }
552
+ if (method !== "GET" && method !== "HEAD") {
553
+ sendError(res, 405, `${method} is not allowed for assets.`);
554
+ return;
555
+ }
556
+ const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
557
+ const target = assetPathFor(root, pathname);
558
+ if (!target.ok) {
559
+ sendError(res, target.status, target.message);
560
+ return;
561
+ }
562
+ const file = target.path;
563
+ let body;
564
+ try {
565
+ if (!(await stat(file)).isFile())
566
+ throw new Error("not a file");
567
+ body = await readFile(file);
568
+ }
569
+ catch {
570
+ sendError(res, 404, `Not found: ${url.pathname}`);
571
+ return;
572
+ }
573
+ // index.html carries the bridge's own description, so the studio knows at
574
+ // boot that it is served by a bridge without probing for one.
575
+ if (basename(file) === "index.html") {
576
+ body = Buffer.from(injectBoot(body.toString("utf8"), this.info), "utf8");
577
+ }
578
+ res.writeHead(200, {
579
+ "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
580
+ "content-length": String(body.length),
581
+ "cache-control": "no-store",
582
+ "x-content-type-options": "nosniff",
583
+ });
584
+ res.end(method === "HEAD" ? undefined : body);
585
+ }
586
+ }
587
+ /** Puts the boot script first in <head>, before the app's module script. */
588
+ export function injectBoot(html, info) {
589
+ const script = bridgeBootScript(info);
590
+ const head = html.indexOf("<head>");
591
+ if (head < 0)
592
+ return `${script}${html}`;
593
+ return html.slice(0, head + 6) + script + html.slice(head + 6);
594
+ }
595
+ /**
596
+ * Starts the bridge on 127.0.0.1. Resolves once it is listening, with the URL
597
+ * to open.
598
+ */
599
+ export async function startStudioServer(options) {
600
+ const dir = resolve(options.dir);
601
+ try {
602
+ if (!(await stat(dir)).isDirectory()) {
603
+ throw new BridgeError(400, `${dir} is not a directory.`);
604
+ }
605
+ }
606
+ catch (err) {
607
+ if (err instanceof BridgeError)
608
+ throw err;
609
+ throw new BridgeError(400, `No such directory: ${dir}`);
610
+ }
611
+ // Before the watcher exists, so its initial scan sees complete pairs rather
612
+ // than a document whose builder file appears underneath it.
613
+ const prepared = options.ensurePairs === true ? await ensureBuilderFiles(dir) : { generated: [], problems: [] };
614
+ const watcher = options.watch === false ? undefined : createWatcher(dir);
615
+ // Seed the ledger with the exact bytes just written. The meta.sourceHash
616
+ // stamp would let the watcher recognize the pair as in sync on its own, but
617
+ // only if the initial scan reaches the file before the user's first edit
618
+ // does; telling it outright removes the race.
619
+ for (const { name, tsText, docText } of prepared.generated) {
620
+ watcher?.noteWrite(name, { tsContent: tsText, docContent: docText });
621
+ }
622
+ const bridge = new Bridge(dir, options.assetsDir === undefined ? undefined : resolve(options.assetsDir), options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS, watcher, options.onEvent);
623
+ const info = {
624
+ protocol: BRIDGE_PROTOCOL,
625
+ dir,
626
+ label: basename(dir),
627
+ token: options.token ?? newSessionToken(),
628
+ };
629
+ const router = new Router(bridge, info, watcher);
630
+ const server = createServer((req, res) => {
631
+ void router.handle(req, res).catch(() => {
632
+ if (!res.headersSent)
633
+ sendError(res, 500, "Internal bridge error.");
634
+ else
635
+ res.end();
636
+ });
637
+ });
638
+ await new Promise((resolveP, rejectP) => {
639
+ server.once("error", rejectP);
640
+ // The host is fixed: see the note at the top of this file.
641
+ server.listen({ host: BRIDGE_HOST, port: options.port ?? 0 }, () => {
642
+ server.removeListener("error", rejectP);
643
+ resolveP();
644
+ });
645
+ });
646
+ const address = server.address();
647
+ const port = typeof address === "object" && address !== null ? address.port : 0;
648
+ return {
649
+ // The token rides in the URL, which is what makes the page the developer
650
+ // opens distinguishable from every other tab in their browser.
651
+ url: `http://${BRIDGE_HOST}:${String(port)}/?${TOKEN_PARAM}=${info.token}`,
652
+ host: BRIDGE_HOST,
653
+ port,
654
+ token: info.token,
655
+ dir,
656
+ prepared,
657
+ server,
658
+ async close() {
659
+ // Parked polls are open connections, so they are answered before the
660
+ // server is asked to stop; otherwise close() waits for the poll timeout.
661
+ await bridge.close();
662
+ const stopped = new Promise((resolveP) => server.close(() => resolveP()));
663
+ server.closeIdleConnections();
664
+ server.closeAllConnections();
665
+ await stopped;
666
+ },
667
+ };
668
+ }
669
+ //# sourceMappingURL=server.js.map