pi-weave 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
package/src/web/server/sse.ts
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The SSE hub — the "tell the browser" half of liveness (weave-workspace §6).
|
|
3
|
-
*
|
|
4
|
-
* A hand-rolled `text/event-stream` over `node:http`. No npm package: the
|
|
5
|
-
* server tier's allowlist is empty (§2), and the wire format is four lines of
|
|
6
|
-
* `res.write`.
|
|
7
|
-
*
|
|
8
|
-
* ## Why `EventSource` and not a WebSocket
|
|
9
|
-
*
|
|
10
|
-
* The traffic is one-directional and tiny — `{scope, stamp}`, at human edit
|
|
11
|
-
* rates. `EventSource` reconnects natively, survives a server restart without
|
|
12
|
-
* client code, and rides the `__Host-weave` cookie automatically, which
|
|
13
|
-
* matters because `EventSource` cannot set request headers (§5.1). A
|
|
14
|
-
* WebSocket would need a framing library, a reconnect loop, and its own
|
|
15
|
-
* authentication path to buy nothing.
|
|
16
|
-
*
|
|
17
|
-
* ## Reconnect: refetch-everything, not `Last-Event-ID`
|
|
18
|
-
*
|
|
19
|
-
* §6 offers a choice and this hub takes the simpler branch, deliberately.
|
|
20
|
-
* Frames still carry `id:` (the stamp), because it costs one line and gives
|
|
21
|
-
* the browser a `lastEventId` worth logging — but **the server keeps no
|
|
22
|
-
* replay buffer and ignores `Last-Event-ID` on reconnect**. The client
|
|
23
|
-
* refetches everything when the stream reopens, which is the behaviour §6
|
|
24
|
-
* describes.
|
|
25
|
-
*
|
|
26
|
-
* A replay buffer would be a *second* source of truth about what changed, and
|
|
27
|
-
* a strictly worse one: frames are coalesced hints, not deltas (§6), so
|
|
28
|
-
* replaying the three frames a client missed is no more informative than the
|
|
29
|
-
* client simply noticing it reconnected. What it actually needs after any gap
|
|
30
|
-
* is one `If-None-Match` refetch of `/api/graph` — a 304 when nothing moved,
|
|
31
|
-
* and correct whether it missed one frame or a thousand. Buffering would add
|
|
32
|
-
* per-client retention, an eviction policy and a resume path to arrive at the
|
|
33
|
-
* same fetch.
|
|
34
|
-
*
|
|
35
|
-
* ## Dedupe
|
|
36
|
-
*
|
|
37
|
-
* A client that already holds the current stamp is not sent it again. The
|
|
38
|
-
* watcher's debounce can still emit two frames for one logical edit (a save
|
|
39
|
-
* that lands either side of the 80 ms window), and the stamp is a **content
|
|
40
|
-
* digest** of the graph payload (§5.3, §15.6), so an identical stamp provably
|
|
41
|
-
* means identical content. Dedupe is **per client**, not global: a client that
|
|
42
|
-
* connected after the last broadcast has not seen it.
|
|
43
|
-
*
|
|
44
|
-
* The digest replaced `generatedAt` here for a reason this hub cannot see but
|
|
45
|
-
* depends on: a timestamp max is unchanged by an edit that does not advance
|
|
46
|
-
* it, so dropping such a frame as a duplicate silently withheld a real change
|
|
47
|
-
* from the client. The frames are only as trustworthy as the key they dedupe
|
|
48
|
-
* on.
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
52
|
-
import { CHANGE_EVENT_NAME, type ChangeEvent } from "../shared/wire";
|
|
53
|
-
|
|
54
|
-
/** §6: a comment frame every 20 s to defeat proxy buffering. */
|
|
55
|
-
export const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The heartbeat is an SSE comment: a line starting with `:` that carries no
|
|
59
|
-
* event and is discarded by every conforming client. It exists to push bytes
|
|
60
|
-
* through an idle connection so that (a) an intermediary does not buffer or
|
|
61
|
-
* reap the stream, and (b) a socket whose peer vanished without a FIN fails
|
|
62
|
-
* its write and is reaped here.
|
|
63
|
-
*/
|
|
64
|
-
const HEARTBEAT_FRAME = ":ping\n\n";
|
|
65
|
-
|
|
66
|
-
/** Recurring-timer injection, so tests never wait 20 s. Mirrors the watcher's. */
|
|
67
|
-
export interface IntervalScheduler {
|
|
68
|
-
/** Run `fn` every `ms`; the returned closure cancels it. */
|
|
69
|
-
repeat(fn: () => void, ms: number): () => void;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** `setInterval`, `unref`'d so a live stream cannot outlive the pi session. */
|
|
73
|
-
export const realIntervalScheduler: IntervalScheduler = {
|
|
74
|
-
repeat(fn, ms) {
|
|
75
|
-
const handle = setInterval(fn, ms);
|
|
76
|
-
handle.unref?.();
|
|
77
|
-
return () => clearInterval(handle);
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The half of `ServerResponse` this hub uses.
|
|
83
|
-
*
|
|
84
|
-
* Narrower than `ServerResponse` on purpose: it documents the entire contract
|
|
85
|
-
* ("headers, writes, an end, and two lifecycle events") and lets a test pass a
|
|
86
|
-
* recording double without constructing a socket. A real `ServerResponse`
|
|
87
|
-
* satisfies it structurally.
|
|
88
|
-
*/
|
|
89
|
-
export interface SseSink {
|
|
90
|
-
writeHead(status: number, headers: Record<string, string>): unknown;
|
|
91
|
-
flushHeaders?: () => void;
|
|
92
|
-
write(chunk: string): boolean;
|
|
93
|
-
end(): unknown;
|
|
94
|
-
on(event: "close" | "error", listener: () => void): unknown;
|
|
95
|
-
readonly writableEnded?: boolean;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* The response headers, as a table so the route test can assert them
|
|
100
|
-
* literally.
|
|
101
|
-
*
|
|
102
|
-
* | Header | Why |
|
|
103
|
-
* | --- | --- |
|
|
104
|
-
* | `Content-Type: text/event-stream` | The format. Without it `EventSource` rejects the response outright. |
|
|
105
|
-
* | `Cache-Control: no-cache, no-transform` | `no-transform` additionally forbids a proxy from gzipping, which would introduce its own buffer. |
|
|
106
|
-
* | `Connection: keep-alive` | HTTP/1.1 explicitness; harmless under HTTP/2. |
|
|
107
|
-
* | `X-Accel-Buffering: no` | nginx-specific, and the one that actually matters in practice: without it nginx buffers the stream and every event arrives in a batch when the connection closes. |
|
|
108
|
-
*/
|
|
109
|
-
export const SSE_HEADERS: Readonly<Record<string, string>> = {
|
|
110
|
-
"Content-Type": "text/event-stream; charset=utf-8",
|
|
111
|
-
"Cache-Control": "no-cache, no-transform",
|
|
112
|
-
Connection: "keep-alive",
|
|
113
|
-
"X-Accel-Buffering": "no",
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Serialize one event.
|
|
118
|
-
*
|
|
119
|
-
* `id:` carries the stamp so the browser exposes `lastEventId` (useful in a
|
|
120
|
-
* log, and the seam if we ever *do* want replay); the server itself keeps no
|
|
121
|
-
* history — see the module header. `retry:` is omitted, leaving the browser's
|
|
122
|
-
* own backoff in charge, which is the behaviour §6 describes.
|
|
123
|
-
*/
|
|
124
|
-
export function formatFrame(event: ChangeEvent): string {
|
|
125
|
-
return `id: ${event.stamp}\nevent: ${CHANGE_EVENT_NAME}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface SseHubOptions {
|
|
129
|
-
/**
|
|
130
|
-
* The stamp a newly attached client should be told about, if any.
|
|
131
|
-
*
|
|
132
|
-
* This is what replaces a replay buffer: rather than reconstructing what a
|
|
133
|
-
* reconnecting client missed, hand it the current stamp and let its
|
|
134
|
-
* `If-None-Match` refetch decide whether anything actually moved. Returning
|
|
135
|
-
* `null` (no graph built yet) simply sends nothing.
|
|
136
|
-
*/
|
|
137
|
-
currentStamp?: () => string | null;
|
|
138
|
-
heartbeatMs?: number;
|
|
139
|
-
scheduler?: IntervalScheduler;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** One attached browser. */
|
|
143
|
-
interface Client {
|
|
144
|
-
sink: SseSink;
|
|
145
|
-
/** Last stamp written to this client; `null` before its first frame. */
|
|
146
|
-
lastStamp: string | null;
|
|
147
|
-
/** Guards against `close` and `error` both firing for the same socket. */
|
|
148
|
-
detached: boolean;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The hub. Owns every open stream and exactly one heartbeat timer.
|
|
153
|
-
*
|
|
154
|
-
* One timer for all clients rather than one per client: the heartbeat is a
|
|
155
|
-
* liveness probe, not a per-connection schedule, and N timers would be N
|
|
156
|
-
* things to leak. It runs only while at least one client is attached, so an
|
|
157
|
-
* idle server holds no timers at all — which is what lets the process exit
|
|
158
|
-
* cleanly when the pi session ends.
|
|
159
|
-
*/
|
|
160
|
-
export class SseHub {
|
|
161
|
-
private readonly clients = new Set<Client>();
|
|
162
|
-
private readonly currentStamp: (() => string | null) | undefined;
|
|
163
|
-
private readonly heartbeatMs: number;
|
|
164
|
-
private readonly scheduler: IntervalScheduler;
|
|
165
|
-
|
|
166
|
-
private cancelHeartbeat: (() => void) | null = null;
|
|
167
|
-
private closed = false;
|
|
168
|
-
|
|
169
|
-
constructor(opts: SseHubOptions = {}) {
|
|
170
|
-
this.currentStamp = opts.currentStamp;
|
|
171
|
-
this.heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
172
|
-
this.scheduler = opts.scheduler ?? realIntervalScheduler;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Adopt a request as an SSE stream.
|
|
177
|
-
*
|
|
178
|
-
* `req` is unused beyond its lifecycle: authentication happened in
|
|
179
|
-
* `security.ts` before the route dispatched here, and `Last-Event-ID` is
|
|
180
|
-
* deliberately ignored (module header). It stays in the signature because
|
|
181
|
-
* that is the shape a route handler has, and because a future replay
|
|
182
|
-
* implementation would need it.
|
|
183
|
-
*
|
|
184
|
-
* After {@link close}, a late request is answered `503` rather than left
|
|
185
|
-
* hanging — a browser retrying into a shutting-down server should learn
|
|
186
|
-
* that immediately.
|
|
187
|
-
*/
|
|
188
|
-
attach(req: IncomingMessage, res: SseSink): void {
|
|
189
|
-
if (this.closed) {
|
|
190
|
-
res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" });
|
|
191
|
-
res.end();
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
res.writeHead(200, { ...SSE_HEADERS });
|
|
195
|
-
// Without this the first frame can sit in Node's header buffer until
|
|
196
|
-
// enough body accumulates, so `EventSource` stays in CONNECTING and the
|
|
197
|
-
// client's status bar lies.
|
|
198
|
-
res.flushHeaders?.();
|
|
199
|
-
|
|
200
|
-
const client: Client = { sink: res, lastStamp: null, detached: false };
|
|
201
|
-
this.clients.add(client);
|
|
202
|
-
|
|
203
|
-
const detach = (): void => this.detach(client);
|
|
204
|
-
res.on("close", detach);
|
|
205
|
-
res.on("error", detach);
|
|
206
|
-
void req;
|
|
207
|
-
|
|
208
|
-
this.startHeartbeat();
|
|
209
|
-
|
|
210
|
-
// The reconnect strategy in one line: tell the fresh client where we are
|
|
211
|
-
// and let its conditional refetch work out whether that means anything.
|
|
212
|
-
const stamp = this.currentStamp?.() ?? null;
|
|
213
|
-
if (stamp !== null) this.send(client, { scope: "vault", stamp });
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Fan out one event, skipping clients that already hold its stamp. */
|
|
217
|
-
broadcast(event: ChangeEvent): void {
|
|
218
|
-
if (this.closed) return;
|
|
219
|
-
for (const client of [...this.clients]) {
|
|
220
|
-
if (client.lastStamp === event.stamp) continue;
|
|
221
|
-
this.send(client, event);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Attached client count. `server.ts`'s idle shutdown reads this. */
|
|
226
|
-
clientCount(): number {
|
|
227
|
-
return this.clients.size;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* End every stream and stop the heartbeat. Idempotent.
|
|
232
|
-
*
|
|
233
|
-
* Ending rather than destroying: the browser sees a clean EOF and applies
|
|
234
|
-
* its normal reconnect backoff, which is right for a restart and harmless
|
|
235
|
-
* for a shutdown (the port is gone and the retry fails fast). A stream left
|
|
236
|
-
* open would keep its socket — and, without the `unref` above, the whole
|
|
237
|
-
* process — alive after the pi session exited.
|
|
238
|
-
*/
|
|
239
|
-
close(): void {
|
|
240
|
-
if (this.closed) return;
|
|
241
|
-
this.closed = true;
|
|
242
|
-
for (const client of [...this.clients]) {
|
|
243
|
-
client.detached = true;
|
|
244
|
-
endQuietly(client.sink);
|
|
245
|
-
}
|
|
246
|
-
this.clients.clear();
|
|
247
|
-
this.stopHeartbeat();
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// --- internals --------------------------------------------------------------
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Write one frame, recording the stamp so the next broadcast can dedupe.
|
|
254
|
-
*
|
|
255
|
-
* A failed write means the peer is gone in a way `close` has not reported
|
|
256
|
-
* yet, so the client is dropped rather than retried — the alternative is
|
|
257
|
-
* writing into a dead socket every 20 s forever.
|
|
258
|
-
*/
|
|
259
|
-
private send(client: Client, event: ChangeEvent): void {
|
|
260
|
-
if (writeQuietly(client.sink, formatFrame(event))) {
|
|
261
|
-
client.lastStamp = event.stamp;
|
|
262
|
-
} else {
|
|
263
|
-
this.detach(client);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
private detach(client: Client): void {
|
|
268
|
-
if (client.detached) return;
|
|
269
|
-
client.detached = true;
|
|
270
|
-
this.clients.delete(client);
|
|
271
|
-
if (this.clients.size === 0) this.stopHeartbeat();
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
private startHeartbeat(): void {
|
|
275
|
-
if (this.cancelHeartbeat !== null) return;
|
|
276
|
-
this.cancelHeartbeat = this.scheduler.repeat(() => this.beat(), this.heartbeatMs);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
private stopHeartbeat(): void {
|
|
280
|
-
this.cancelHeartbeat?.();
|
|
281
|
-
this.cancelHeartbeat = null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** One heartbeat round; also the reaper for sockets that died silently. */
|
|
285
|
-
private beat(): void {
|
|
286
|
-
for (const client of [...this.clients]) {
|
|
287
|
-
if (!writeQuietly(client.sink, HEARTBEAT_FRAME)) this.detach(client);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** `write`, reporting failure as `false` instead of throwing. */
|
|
293
|
-
function writeQuietly(sink: SseSink, chunk: string): boolean {
|
|
294
|
-
if (sink.writableEnded === true) return false;
|
|
295
|
-
try {
|
|
296
|
-
// `write` returning false is backpressure, not failure — these frames are
|
|
297
|
-
// tens of bytes, so the buffer absorbing them is a success.
|
|
298
|
-
sink.write(chunk);
|
|
299
|
-
return true;
|
|
300
|
-
} catch {
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function endQuietly(sink: SseSink): void {
|
|
306
|
-
try {
|
|
307
|
-
sink.end();
|
|
308
|
-
} catch {
|
|
309
|
-
// The socket died between the liveness check and the write. Shutdown must
|
|
310
|
-
// not fail because a client left first.
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Structural check that a real `ServerResponse` satisfies {@link SseSink}.
|
|
316
|
-
*
|
|
317
|
-
* Purely a compile-time assertion: if `SseSink` ever drifts from the API
|
|
318
|
-
* `node:http` actually offers, `npm run typecheck` fails here rather than at
|
|
319
|
-
* the call site in `server.ts`, which is owned by someone else.
|
|
320
|
-
*/
|
|
321
|
-
export type SseSinkIsServerResponse = ServerResponse extends SseSink ? true : never;
|