pi-weave 0.1.7 → 0.1.9

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 (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * The four security layers of the loopback workspace server
3
+ * (weave-workspace §5.1).
4
+ *
5
+ * ## Why any of this, on 127.0.0.1
6
+ *
7
+ * The retired viewer bound loopback and stopped there —
8
+ * `git show cef1177:src/pi/viewer/server.ts` has **zero** matches for
9
+ * `token`, `Origin` or `Host`. Loopback is not an authorisation boundary:
10
+ *
11
+ * - every process running as the user can reach the port, and can find it
12
+ * by scanning a few thousand ports faster than a page loads;
13
+ * - **any website the user visits** can reach it too, via DNS rebinding —
14
+ * `evil.com` resolves to a real address for the page load, then re-answers
15
+ * with `127.0.0.1` on a second lookup, and the browser now treats our
16
+ * server as same-origin with the attacker's JavaScript.
17
+ *
18
+ * So four layers, each defeating something the others do not:
19
+ *
20
+ * | # | Layer | Defeats |
21
+ * | - | ----- | ------- |
22
+ * | 1 | bind `127.0.0.1`, port `0` | remote reachability; a guessable port |
23
+ * | 2 | {@link isAllowedHost} | DNS rebinding |
24
+ * | 3 | {@link timingSafeMatch} over a 256-bit token | local processes, blind CSRF |
25
+ * | 4 | {@link checkOrigin} | cross-origin writes from a page that got past 2 |
26
+ *
27
+ * Layer 2 is the one that carries the rebinding case, and the reason it
28
+ * works is worth stating: the rebinding attacker controls DNS, not the
29
+ * browser. The browser sends `Host: evil.com` because that is the name in
30
+ * the URL bar, and it cannot be talked out of it — `fetch` rejects an
31
+ * attempt to set `Host` as a forbidden header. An allowlist of
32
+ * `127.0.0.1:PORT` / `localhost:PORT` / `[::1]:PORT` therefore rejects every
33
+ * rebound request while costing a legitimate one a string compare.
34
+ *
35
+ * Layer 4 is deliberately asymmetric, because `Origin` is asymmetric:
36
+ * browsers omit it on same-origin GET *navigations*, so requiring it would
37
+ * break typing the URL into the address bar. Validate-if-present on GET,
38
+ * require on everything else — which is where state changes live.
39
+ *
40
+ * ## Everything here is a pure function of request facts
41
+ *
42
+ * {@link RequestFacts} is four strings, not an `IncomingMessage`, so the
43
+ * table tests in `tests/web/security.test.ts` enumerate the matrix directly
44
+ * instead of constructing sockets. {@link requestFacts} is the one adapter
45
+ * from Node's request object, and it is the only part of this module that
46
+ * knows HTTP exists.
47
+ */
48
+
49
+ import { randomBytes, timingSafeEqual } from "node:crypto";
50
+
51
+ // --- token -------------------------------------------------------------------
52
+
53
+ /**
54
+ * Token entropy. 256 bits: not guessable by a local process in the lifetime
55
+ * of a pi session, and short enough in base64url (43 chars) to survive being
56
+ * pasted into a terminal.
57
+ */
58
+ export const TOKEN_BYTES = 32;
59
+
60
+ /** A fresh per-session token. */
61
+ export function generateToken(): string {
62
+ return randomBytes(TOKEN_BYTES).toString("base64url");
63
+ }
64
+
65
+ /**
66
+ * Constant-time string comparison.
67
+ *
68
+ * `timingSafeEqual` **throws** on a length mismatch (`ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`),
69
+ * which is the trap this wrapper exists to close: a caller that passes it
70
+ * two user-controlled strings has written a crash, not a comparison. The
71
+ * length pre-check is not a timing leak worth caring about — our token's
72
+ * length is a constant of the program, so an attacker learns nothing they
73
+ * could not read in this file.
74
+ *
75
+ * Byte length, not `String.length`: a multi-byte character would make the
76
+ * two disagree and re-introduce the throw.
77
+ */
78
+ export function timingSafeMatch(a: string, b: string): boolean {
79
+ const left = Buffer.from(a, "utf8");
80
+ const right = Buffer.from(b, "utf8");
81
+ if (left.length !== right.length) return false;
82
+ // Zero-length buffers compare equal in `timingSafeEqual`, which would make
83
+ // "" a valid token against an "" secret. Callers never hold an empty
84
+ // token, but a truncated env var is one typo away, so refuse explicitly.
85
+ if (left.length === 0) return false;
86
+ return timingSafeEqual(left, right);
87
+ }
88
+
89
+ // --- host and origin ---------------------------------------------------------
90
+
91
+ /**
92
+ * The `Host` values a legitimate request can carry. IPv6 loopback is
93
+ * included in bracketed form because that is what a browser sends for
94
+ * `http://[::1]:PORT/`.
95
+ */
96
+ export function allowedHosts(port: number): ReadonlySet<string> {
97
+ return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
98
+ }
99
+
100
+ /** The `Origin` values matching {@link allowedHosts}. Always `http:`. */
101
+ export function allowedOrigins(port: number): ReadonlySet<string> {
102
+ return new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`]);
103
+ }
104
+
105
+ /**
106
+ * Layer 2. An absent `Host` is a deny: HTTP/1.1 requires it, and the only
107
+ * clients that omit it are hand-rolled ones probing the port.
108
+ */
109
+ export function isAllowedHost(host: string | undefined, port: number): boolean {
110
+ if (host === undefined) return false;
111
+ return allowedHosts(port).has(host);
112
+ }
113
+
114
+ /**
115
+ * Layer 4. Returns `true` when the request may proceed.
116
+ *
117
+ * - present and allowlisted → ok, any method;
118
+ * - present and foreign → denied, any method (this is the cross-origin
119
+ * write, and also a cross-origin read attempt worth refusing early);
120
+ * - absent on `GET`/`HEAD` → ok, because browsers omit it on same-origin
121
+ * navigations and we would otherwise reject the address bar;
122
+ * - absent on anything else → denied. A state-changing request with no
123
+ * provenance is exactly the shape of a form-post CSRF.
124
+ */
125
+ export function checkOrigin(method: string, origin: string | undefined, port: number): boolean {
126
+ if (origin !== undefined) return allowedOrigins(port).has(origin);
127
+ const safe = method === "GET" || method === "HEAD";
128
+ return safe;
129
+ }
130
+
131
+ // --- cookies -----------------------------------------------------------------
132
+
133
+ /**
134
+ * Default cookie name. The `__Host-` prefix is a browser-enforced
135
+ * commitment: the cookie must be `Secure`, must have `Path=/`, and must
136
+ * carry **no** `Domain` — which together mean no sibling origin and no
137
+ * subdomain can set or overwrite it. That closes cookie-injection, where an
138
+ * attacker plants their own token so that any request the user makes is
139
+ * attributed to a session the attacker also holds.
140
+ *
141
+ * `Secure` on `http://127.0.0.1` is legal because loopback is a
142
+ * [secure context](https://w3c.github.io/webappsec-secure-contexts/), and
143
+ * Chrome/Firefox/Safari all honour it there. §5.1 footnote 1 asks for a
144
+ * fallback anyway, which is {@link FALLBACK_COOKIE_NAME}: same value, no
145
+ * prefix, no `Secure`, and therefore none of the guarantees above. It exists
146
+ * so a browser that disagrees costs a config flag rather than a redesign.
147
+ */
148
+ export const DEFAULT_COOKIE_NAME = "__Host-weave";
149
+
150
+ /** Prefix-free fallback for a browser that rejects `Secure` on loopback. */
151
+ export const FALLBACK_COOKIE_NAME = "weave_token";
152
+
153
+ /** Query parameter carrying the token on the one-shot handoff URL. */
154
+ export const TOKEN_QUERY_PARAM = "t";
155
+
156
+ /**
157
+ * Parse a `Cookie` request header into a map.
158
+ *
159
+ * Lenient by design — this is header parsing on an attacker-reachable path,
160
+ * so anything malformed is skipped rather than thrown. A pair with no `=` is
161
+ * dropped; a value containing `=` keeps it (base64url does not produce one,
162
+ * but a fallback-named cookie set by something else might).
163
+ */
164
+ export function parseCookies(header: string | undefined): Record<string, string> {
165
+ const out: Record<string, string> = {};
166
+ if (header === undefined) return out;
167
+ for (const part of header.split(";")) {
168
+ const eq = part.indexOf("=");
169
+ // `eq === 0` is a nameless pair ("=v"); `eq === -1` has no separator at
170
+ // all. Both are malformed, and `<= 0` covers them together — which also
171
+ // means a name can never be the empty string below.
172
+ if (eq <= 0) continue;
173
+ out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * The `Set-Cookie` value for the handoff.
180
+ *
181
+ * `HttpOnly` keeps the token out of `document.cookie`, so an XSS in the
182
+ * client (or in a note's rendered Markdown) cannot exfiltrate it.
183
+ * `SameSite=Strict` means the browser will not attach it to any
184
+ * cross-site-initiated request at all, which is a second, independent answer
185
+ * to the CSRF that {@link checkOrigin} covers.
186
+ *
187
+ * No `Max-Age`: a session cookie dies with the browser session, and the
188
+ * server dies with the pi session, so persisting it would only widen the
189
+ * window in which a stale token exists.
190
+ */
191
+ export function buildSetCookie(name: string, token: string): string {
192
+ const attrs = [`${name}=${token}`, "HttpOnly", "SameSite=Strict", "Path=/"];
193
+ // `Secure` is mandatory for `__Host-`; the fallback name exists precisely
194
+ // to be usable without it.
195
+ if (name.startsWith("__Host-")) attrs.push("Secure");
196
+ return attrs.join("; ");
197
+ }
198
+
199
+ // --- the decision ------------------------------------------------------------
200
+
201
+ /**
202
+ * Everything the policy needs from a request. Four strings, so the matrix is
203
+ * table-testable without a socket.
204
+ */
205
+ export interface RequestFacts {
206
+ method: string;
207
+ /** Request target as sent, e.g. `/api/graph?x=1`. Never a full URL. */
208
+ url: string;
209
+ host: string | undefined;
210
+ origin: string | undefined;
211
+ /** Raw `Cookie` header. */
212
+ cookie: string | undefined;
213
+ }
214
+
215
+ /** Minimal structural view of a Node request — avoids importing `http` types. */
216
+ export interface IncomingLike {
217
+ method?: string | undefined;
218
+ url?: string | undefined;
219
+ headers: Record<string, string | string[] | undefined>;
220
+ }
221
+
222
+ /**
223
+ * Read {@link RequestFacts} off a Node request.
224
+ *
225
+ * Node lower-cases header names and collapses duplicates for these four, but
226
+ * a duplicated `Host` still arrives as an array on some proxies; taking the
227
+ * first would let an attacker append an allowlisted value to a foreign one.
228
+ * An array is therefore treated as absent, which fails closed.
229
+ */
230
+ export function requestFacts(req: IncomingLike): RequestFacts {
231
+ return {
232
+ method: req.method ?? "GET",
233
+ url: req.url ?? "/",
234
+ host: single(req.headers["host"]),
235
+ origin: single(req.headers["origin"]),
236
+ cookie: single(req.headers["cookie"]),
237
+ };
238
+ }
239
+
240
+ function single(value: string | string[] | undefined): string | undefined {
241
+ return typeof value === "string" ? value : undefined;
242
+ }
243
+
244
+ /**
245
+ * What the server should do with a request.
246
+ *
247
+ * - `allow`: proceed to routing.
248
+ * - `handoff`: the URL carried a valid token; set the cookie and `302` to
249
+ * {@link SecurityDecision.location}, dropping the token from the address
250
+ * bar (and therefore from `Referer`, from shell history, and from
251
+ * anything that later reads the tab's URL).
252
+ * - `deny`: respond `403` with `reason`. The reason is for the server log
253
+ * and the test table, never for the response body — telling a prober
254
+ * which layer stopped them is free reconnaissance.
255
+ */
256
+ export type SecurityDecision =
257
+ | { kind: "allow" }
258
+ | { kind: "handoff"; location: string; setCookie: string }
259
+ | { kind: "deny"; status: 403; reason: DenyReason };
260
+
261
+ export type DenyReason = "host" | "origin" | "token";
262
+
263
+ export interface SecurityOptions {
264
+ port: number;
265
+ /** Defaults to a fresh {@link generateToken}. */
266
+ token?: string;
267
+ /** Defaults to {@link DEFAULT_COOKIE_NAME}. */
268
+ cookieName?: string;
269
+ }
270
+
271
+ export interface SecurityPolicy {
272
+ readonly token: string;
273
+ readonly cookieName: string;
274
+ /** `http://127.0.0.1:PORT` — the canonical origin of this server. */
275
+ readonly origin: string;
276
+ /** The one-shot handoff URL to hand to a browser. */
277
+ readonly entryUrl: string;
278
+ authorize(facts: RequestFacts): SecurityDecision;
279
+ }
280
+
281
+ /**
282
+ * Bind the four layers to a port and a token.
283
+ *
284
+ * A factory rather than free functions with a port argument everywhere: the
285
+ * allowlists are derived from the port, and deriving them once at bind time
286
+ * makes it impossible to check a request against the wrong port — the class
287
+ * of bug where a server restarts on a new port and an allowlist captured
288
+ * earlier keeps accepting the old one.
289
+ */
290
+ export function createSecurityPolicy(opts: SecurityOptions): SecurityPolicy {
291
+ const token = opts.token ?? generateToken();
292
+ const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
293
+ const { port } = opts;
294
+ const origin = `http://127.0.0.1:${port}`;
295
+ const setCookie = buildSetCookie(cookieName, token);
296
+
297
+ return {
298
+ token,
299
+ cookieName,
300
+ origin,
301
+ entryUrl: `${origin}/?${TOKEN_QUERY_PARAM}=${token}`,
302
+
303
+ authorize(facts: RequestFacts): SecurityDecision {
304
+ // Order matters: cheapest and most decisive first, and never leak
305
+ // whether a token was *nearly* right to a caller who failed layer 2.
306
+ if (!isAllowedHost(facts.host, port)) return { kind: "deny", status: 403, reason: "host" };
307
+ if (!checkOrigin(facts.method, facts.origin, port)) {
308
+ return { kind: "deny", status: 403, reason: "origin" };
309
+ }
310
+
311
+ const cookies = parseCookies(facts.cookie);
312
+ const held = cookies[cookieName];
313
+ if (held !== undefined && timingSafeMatch(held, token)) return { kind: "allow" };
314
+
315
+ // No valid cookie. A GET may still be the one-shot handoff.
316
+ const query = queryToken(facts.url);
317
+ if (query !== null && facts.method === "GET" && timingSafeMatch(query, token)) {
318
+ return { kind: "handoff", location: stripTokenParam(facts.url), setCookie };
319
+ }
320
+ return { kind: "deny", status: 403, reason: "token" };
321
+ },
322
+ };
323
+ }
324
+
325
+ /**
326
+ * The `t` parameter of a request target, or `null`.
327
+ *
328
+ * Parsed against a fixed dummy base because `req.url` is a path, not an
329
+ * absolute URL, and `new URL` demands one. The base is never used for
330
+ * anything but parsing.
331
+ */
332
+ function queryToken(url: string): string | null {
333
+ return parseTarget(url).searchParams.get(TOKEN_QUERY_PARAM);
334
+ }
335
+
336
+ /**
337
+ * The same target with `t` removed — the `302` location.
338
+ *
339
+ * Other parameters survive, so a deep link like `/?t=…&note=alpha` still
340
+ * lands on the note after the handoff.
341
+ */
342
+ function stripTokenParam(url: string): string {
343
+ const parsed = parseTarget(url);
344
+ parsed.searchParams.delete(TOKEN_QUERY_PARAM);
345
+ const query = parsed.searchParams.toString();
346
+ return query.length > 0 ? `${parsed.pathname}?${query}` : parsed.pathname;
347
+ }
348
+
349
+ /** Dummy origin for parsing an origin-form request target. */
350
+ const PARSE_BASE = "http://weave.invalid";
351
+
352
+ function parseTarget(url: string): URL {
353
+ try {
354
+ return new URL(url, PARSE_BASE);
355
+ } catch {
356
+ // `new URL` is remarkably hard to break with an origin-form target, but
357
+ // the request line is attacker-controlled and a throw here would be a
358
+ // 500 on an unauthenticated path. Fail closed to a token-less root.
359
+ return new URL("/", PARSE_BASE);
360
+ }
361
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The loopback workspace server — bind, lifecycle, teardown
3
+ * (weave-workspace §5.1, §5.4).
4
+ *
5
+ * This module owns three things and delegates everything else:
6
+ *
7
+ * 1. **Binding.** `listen(0, "127.0.0.1")` — loopback only, ephemeral port.
8
+ * Never a fixed port: a fixed port is a port another process can squat
9
+ * before us, and a port a malicious page can guess without scanning.
10
+ * 2. **Composition.** It builds the {@link RouteDeps} — a
11
+ * {@link WorkspaceCache}, a {@link SecurityPolicy}, and whichever hub and
12
+ * watcher it was handed — and passes them to `handleRequest`.
13
+ * 3. **Lifecycle.** Idle shutdown 30 minutes after the last SSE client
14
+ * disconnects, and a `close()` that actually releases the port.
15
+ *
16
+ * ## Why the hub and the watcher are constructor arguments
17
+ *
18
+ * `SseHub` and `Watcher` are declared in `routes.ts` (the consumer) and
19
+ * arrive here as optional options. That is not ceremony: it means the server
20
+ * can be booted with neither — which is exactly what the route tests do, so
21
+ * they exercise the real socket and the real security policy without a file
22
+ * watcher spinning up on a temp directory, and it means the SSE
23
+ * implementation can be written, replaced, or omitted with no edit to this
24
+ * file. With no hub, `/events` answers `503` and the server has no idle
25
+ * timer to run, because nothing can be idle.
26
+ *
27
+ * ## Idle shutdown, precisely
28
+ *
29
+ * The timer starts when the SSE client count reaches zero and is cancelled
30
+ * when a client attaches. A workspace with a tab open is never idle; a
31
+ * workspace whose last tab closed is reclaimed after
32
+ * {@link DEFAULT_IDLE_MS}. `setTimer` is injectable, so the test asserts the
33
+ * transition rather than waiting thirty minutes for it, and
34
+ * `timer.unref?.()` keeps a pending shutdown from holding the process open.
35
+ */
36
+
37
+ import { randomBytes } from "node:crypto";
38
+ import { createServer as createHttpServer, type Server } from "node:http";
39
+ import { fileURLToPath } from "node:url";
40
+ import { WorkspaceCache } from "../../core/cache/workspace";
41
+ import { resolveVaultRoot } from "../../core/paths";
42
+ import { handleRequest, type RouteDeps, type SseHub, type Watcher } from "./routes";
43
+ import { createSecurityPolicy, type SecurityPolicy } from "./security";
44
+
45
+ export type { SseHub, Watcher } from "./routes";
46
+
47
+ /** §5.4: reclaim the port 30 minutes after the last SSE client disconnects. */
48
+ export const DEFAULT_IDLE_MS = 30 * 60 * 1000;
49
+
50
+ /**
51
+ * The committed bundle, resolved relative to this module rather than to
52
+ * `process.cwd()` — the server runs in whatever directory the user invoked
53
+ * pi from, which is never the package root.
54
+ */
55
+ export function defaultBundlePath(): string {
56
+ return fileURLToPath(new URL("../client/dist/app.js", import.meta.url));
57
+ }
58
+
59
+ /** A `setTimeout` shaped seam, so the idle path is testable in microseconds. */
60
+ export interface TimerHandle {
61
+ unref?(): unknown;
62
+ }
63
+ export type SetTimer = (fn: () => void, ms: number) => TimerHandle;
64
+ export type ClearTimer = (handle: TimerHandle) => void;
65
+
66
+ export interface StartWorkspaceServerOptions {
67
+ cwd: string;
68
+ /** Defaults to `resolveVaultRoot()`, which honours `PI_WEAVE_VAULT`. */
69
+ vaultRoot?: string;
70
+ /**
71
+ * Live-update hub. Absent → `/events` answers `503` and the idle timer
72
+ * never runs. Supplied by the caller that also constructs the watcher.
73
+ */
74
+ sse?: SseHub | undefined;
75
+ /** File watcher. Started during boot, closed during teardown. */
76
+ watcher?: Watcher | undefined;
77
+ /** Pre-built cache. Defaults to a fresh one over `cwd` + `vaultRoot`. */
78
+ cache?: WorkspaceCache | undefined;
79
+ /** Fixed token, for tests that need to know it before the boot resolves. */
80
+ token?: string | undefined;
81
+ /** Cookie name override — the §5.1 footnote-1 fallback. */
82
+ cookieName?: string | undefined;
83
+ /** Absolute path to the client bundle. Defaults to the committed one. */
84
+ bundlePath?: string | undefined;
85
+ /** Test seam for `POST /api/open`. */
86
+ openNote?: ((slug: string) => Promise<boolean>) | undefined;
87
+ /** Test seam for the note read; see {@link RouteDeps.readNote}. */
88
+ readNote?: RouteDeps["readNote"];
89
+ /**
90
+ * Self-write suppression (§6): tell the watcher to ignore a path it is
91
+ * about to see us change.
92
+ *
93
+ * A separate option rather than a method on {@link Watcher}, because
94
+ * `Watcher` here is the two-method lifecycle contract `routes.ts` declares
95
+ * — start and close — and widening it would force every future watcher
96
+ * (a poller, a remote FS) to implement suppression whether or not the
97
+ * concept applies to it. The caller that constructs the real watcher knows
98
+ * it has a `suppress`, and passes it.
99
+ *
100
+ * Defaults to {@link Watcher.suppress} when the injected watcher has one,
101
+ * so the common wiring is automatic and the option exists for a caller
102
+ * whose suppression lives somewhere else (or for a test that wants to
103
+ * observe it). Absent on both means writes happen unsuppressed, which is
104
+ * exactly right for a server booted with no watcher: there is nothing
105
+ * listening to feed a loop.
106
+ */
107
+ suppress?: ((absPath: string) => void) | undefined;
108
+ /** Idle shutdown delay. `0` disables it entirely. */
109
+ idleMs?: number | undefined;
110
+ setTimer?: SetTimer | undefined;
111
+ clearTimer?: ClearTimer | undefined;
112
+ /** Invoked when the idle timeout fires, after `close()`. */
113
+ onIdleShutdown?: (() => void) | undefined;
114
+ }
115
+
116
+ export interface WorkspaceServer {
117
+ /** Canonical base URL, e.g. `http://127.0.0.1:53217`. */
118
+ url: string;
119
+ /** The one-shot handoff URL: `url` plus `?t=TOKEN`. Give this to a browser. */
120
+ entryUrl: string;
121
+ port: number;
122
+ token: string;
123
+ security: SecurityPolicy;
124
+ cache: WorkspaceCache;
125
+ /** Random per-boot id, also embedded in the page bootstrap. */
126
+ session: string;
127
+ /**
128
+ * Called by the SSE hub whenever a client attaches or detaches, so the
129
+ * idle timer can be armed or cancelled. Exposed rather than inferred
130
+ * because only the hub knows when a socket actually died.
131
+ */
132
+ noteActivity(): void;
133
+ /** Release the port, the watcher and every stream. Idempotent. */
134
+ close(): Promise<void>;
135
+ }
136
+
137
+ export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): Promise<WorkspaceServer> {
138
+ const vaultRoot = opts.vaultRoot ?? resolveVaultRoot();
139
+ const cache = opts.cache ?? new WorkspaceCache({ cwd: opts.cwd, vaultRoot });
140
+ const session = randomBytes(8).toString("hex");
141
+ const setTimer: SetTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
142
+ const clearTimer: ClearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as NodeJS.Timeout));
143
+ const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
144
+
145
+ // `deps` is assembled before `listen` because the request handler closes
146
+ // over it, but the security policy needs the bound port — so the policy
147
+ // slot is filled after binding and the handler reads it through the
148
+ // mutable holder rather than capturing a stale value.
149
+ // Explicit `suppress` first, else the watcher's own if it has one (§6).
150
+ // Bound as a closure rather than passed as a method reference, so a
151
+ // watcher implementing `suppress` as a class method keeps its `this`.
152
+ const watcherSuppress = opts.watcher?.suppress;
153
+ const suppress: ((absPath: string) => void) | null =
154
+ opts.suppress ?? (watcherSuppress === undefined ? null : (absPath: string) => watcherSuppress.call(opts.watcher, absPath));
155
+
156
+ let deps: RouteDeps | null = null;
157
+ const server: Server = createHttpServer((req, res) => {
158
+ // Unreachable in practice — `listen` resolves before any connection is
159
+ // accepted — but a request arriving with no deps would be a `500` with
160
+ // an incomprehensible stack, so answer honestly instead.
161
+ /* c8 ignore next 5 */
162
+ if (deps === null) {
163
+ res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
164
+ res.end("pi-weave: starting\n");
165
+ return;
166
+ }
167
+ void handleRequest(deps, req, res);
168
+ });
169
+
170
+ await listen(server);
171
+ const port = boundPort(server);
172
+ const security = createSecurityPolicy({
173
+ port,
174
+ ...(opts.token !== undefined ? { token: opts.token } : {}),
175
+ ...(opts.cookieName !== undefined ? { cookieName: opts.cookieName } : {}),
176
+ });
177
+
178
+ let closed = false;
179
+ let idleHandle: TimerHandle | null = null;
180
+
181
+ const cancelIdle = (): void => {
182
+ if (idleHandle !== null) {
183
+ clearTimer(idleHandle);
184
+ idleHandle = null;
185
+ }
186
+ };
187
+
188
+ const armIdle = (): void => {
189
+ // No hub means `/events` answers 503, so there is no such thing as a
190
+ // client here and nothing for a countdown to count. The server stays up
191
+ // until its owner closes it.
192
+ if (idleMs <= 0 || closed || opts.sse === undefined) return;
193
+ const handle = setTimer(() => {
194
+ idleHandle = null;
195
+ void close().then(() => opts.onIdleShutdown?.());
196
+ }, idleMs);
197
+ // A pending shutdown must not be the reason the process stays alive.
198
+ handle.unref?.();
199
+ idleHandle = handle;
200
+ };
201
+
202
+ const noteActivity = (): void => {
203
+ cancelIdle();
204
+ // Zero clients right now means the countdown starts now. A hub that
205
+ // reports a live client cancels it and says nothing more until that
206
+ // client leaves. With no hub at all, `armIdle` is already a no-op — the
207
+ // guard lives there rather than being repeated here.
208
+ if (opts.sse === undefined || opts.sse.clientCount() === 0) armIdle();
209
+ };
210
+
211
+ const close = async (): Promise<void> => {
212
+ if (closed) return;
213
+ closed = true;
214
+ cancelIdle();
215
+ opts.sse?.close();
216
+ await opts.watcher?.close();
217
+ await new Promise<void>((resolve) => {
218
+ server.close(() => resolve());
219
+ // Without this, an open SSE stream keeps the server's handle alive and
220
+ // `close()` never resolves — the exact reason a pi session used to
221
+ // hang on shutdown.
222
+ server.closeAllConnections();
223
+ });
224
+ };
225
+
226
+ deps = {
227
+ cwd: opts.cwd,
228
+ vaultRoot,
229
+ session,
230
+ cache,
231
+ security,
232
+ bundlePath: opts.bundlePath ?? defaultBundlePath(),
233
+ ...(opts.sse !== undefined ? { sse: opts.sse } : {}),
234
+ ...(opts.openNote !== undefined ? { openNote: opts.openNote } : {}),
235
+ ...(opts.readNote !== undefined ? { readNote: opts.readNote } : {}),
236
+ ...(suppress === null ? {} : { suppress }),
237
+ onActivity: noteActivity,
238
+ };
239
+
240
+ await opts.watcher?.start();
241
+
242
+ // A workspace nobody has connected to yet is already idle: if the browser
243
+ // never opens, the port should still be reclaimed.
244
+ if (opts.sse !== undefined) armIdle();
245
+
246
+ return {
247
+ url: security.origin,
248
+ entryUrl: security.entryUrl,
249
+ port,
250
+ token: security.token,
251
+ security,
252
+ cache,
253
+ session,
254
+ noteActivity,
255
+ close,
256
+ };
257
+ }
258
+
259
+ /** Bind to an ephemeral loopback port, rejecting on a bind error. */
260
+ function listen(server: Server): Promise<void> {
261
+ return new Promise<void>((resolve, reject) => {
262
+ server.once("error", reject);
263
+ server.listen(0, "127.0.0.1", () => {
264
+ server.off("error", reject);
265
+ resolve();
266
+ });
267
+ });
268
+ }
269
+
270
+ function boundPort(server: Server): number {
271
+ const address = server.address();
272
+ /* c8 ignore next -- `address()` is only a string for a unix socket, which we never bind. */
273
+ if (typeof address !== "object" || address === null) throw new Error("pi-weave: server did not bind a TCP port");
274
+ return address.port;
275
+ }