@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.
- package/LICENSE +202 -0
- package/README.md +446 -0
- package/dist/aws.d.ts +28 -0
- package/dist/aws.d.ts.map +1 -0
- package/dist/aws.js +104 -0
- package/dist/aws.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +132 -0
- package/dist/bin.js.map +1 -0
- package/dist/bridge/exportFiles.d.ts +16 -0
- package/dist/bridge/exportFiles.d.ts.map +1 -0
- package/dist/bridge/exportFiles.js +95 -0
- package/dist/bridge/exportFiles.js.map +1 -0
- package/dist/bridge/pair.d.ts +103 -0
- package/dist/bridge/pair.d.ts.map +1 -0
- package/dist/bridge/pair.js +219 -0
- package/dist/bridge/pair.js.map +1 -0
- package/dist/bridge/protocol.d.ts +187 -0
- package/dist/bridge/protocol.d.ts.map +1 -0
- package/dist/bridge/protocol.js +132 -0
- package/dist/bridge/protocol.js.map +1 -0
- package/dist/bridge/server.d.ts +95 -0
- package/dist/bridge/server.d.ts.map +1 -0
- package/dist/bridge/server.js +669 -0
- package/dist/bridge/server.js.map +1 -0
- package/dist/cdk-scaffold.d.ts +16 -0
- package/dist/cdk-scaffold.d.ts.map +1 -0
- package/dist/cdk-scaffold.js +28 -0
- package/dist/cdk-scaffold.js.map +1 -0
- package/dist/codegen.d.ts +5 -0
- package/dist/codegen.d.ts.map +1 -0
- package/dist/codegen.js +27 -0
- package/dist/codegen.js.map +1 -0
- package/dist/diff.d.ts +33 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +225 -0
- package/dist/diff.js.map +1 -0
- package/dist/docs.d.ts +46 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +223 -0
- package/dist/docs.js.map +1 -0
- package/dist/emit.d.ts +8 -0
- package/dist/emit.d.ts.map +1 -0
- package/dist/emit.js +55 -0
- package/dist/emit.js.map +1 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +27 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +126 -0
- package/dist/export.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/lint.d.ts +6 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +31 -0
- package/dist/lint.js.map +1 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +49 -0
- package/dist/render.js.map +1 -0
- package/dist/run.d.ts +13 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +38 -0
- package/dist/run.js.map +1 -0
- package/dist/simulate.d.ts +35 -0
- package/dist/simulate.d.ts.map +1 -0
- package/dist/simulate.js +194 -0
- package/dist/simulate.js.map +1 -0
- package/dist/studio.d.ts +23 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +120 -0
- package/dist/studio.js.map +1 -0
- package/dist/synth-resolve-hook.d.ts +19 -0
- package/dist/synth-resolve-hook.d.ts.map +1 -0
- package/dist/synth-resolve-hook.js +127 -0
- package/dist/synth-resolve-hook.js.map +1 -0
- package/dist/synth-runner.d.ts +12 -0
- package/dist/synth-runner.d.ts.map +1 -0
- package/dist/synth-runner.js +145 -0
- package/dist/synth-runner.js.map +1 -0
- package/dist/synth.d.ts +55 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +327 -0
- package/dist/synth.js.map +1 -0
- package/dist/watch.d.ts +66 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +293 -0
- package/dist/watch.js.map +1 -0
- package/package.json +85 -0
- package/schema/flowdoc-0.1.schema.json +669 -0
- package/schema/scenario-0.1.schema.json +318 -0
- package/src/aws.ts +149 -0
- package/src/bin.ts +179 -0
- package/src/bridge/exportFiles.ts +116 -0
- package/src/bridge/pair.ts +312 -0
- package/src/bridge/protocol.ts +289 -0
- package/src/bridge/server.ts +827 -0
- package/src/cdk-scaffold.ts +41 -0
- package/src/codegen.ts +36 -0
- package/src/diff.ts +279 -0
- package/src/docs.ts +249 -0
- package/src/emit.ts +69 -0
- package/src/errors.ts +27 -0
- package/src/export.ts +167 -0
- package/src/index.ts +27 -0
- package/src/lint.ts +42 -0
- package/src/render.ts +58 -0
- package/src/run.ts +42 -0
- package/src/simulate.ts +244 -0
- package/src/studio.ts +143 -0
- package/src/synth-resolve-hook.ts +152 -0
- package/src/synth-runner.ts +157 -0
- package/src/synth.ts +380 -0
- 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
|