create-caspian-app 1.0.0 → 1.0.2

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.
@@ -1,585 +1,585 @@
1
- /**
2
- * Browser console -> dev terminal bridge (development only).
3
- *
4
- * Why this exists
5
- * ---------------
6
- * PulsePoint reports every runtime problem through `console.error` with a
7
- * `[PP-ERROR]` / `[PP-WARN]` prefix. Those never reached the `npm run dev`
8
- * terminal, so a broken route looked identical to a working one unless someone
9
- * happened to have DevTools open. An agent editing templates had no feedback
10
- * signal at all.
11
- *
12
- * This wires the browser back to the terminal that is already running:
13
- * BrowserSync proxies every request, so a middleware on `POST /__pp-devlog`
14
- * costs no extra port.
15
- *
16
- * The `<script src="/__pp-devlog.js">` tag is added by `_inject_dev_console_bridge`
17
- * in `main.py`, gated on `CASPIAN_BROWSER_SYNC_PORT` -- the variable
18
- * `settings/python-server.ts` sets only when the dev stack spawns the server.
19
- * BrowserSync's own `snippetOptions` injection does not fire against this app's
20
- * proxied responses (its client snippet is absent from them entirely), so the
21
- * tag has to come from the render pipeline rather than from the proxy.
22
- *
23
- * Transport choice: plain HTTP, not the BrowserSync socket. The errors worth
24
- * catching fire during `pp.mount()` at page load, which is often *before* the
25
- * BrowserSync client socket finishes connecting -- a socket bridge would drop
26
- * exactly the messages this exists to surface. A POST works from the first
27
- * millisecond of page life.
28
- *
29
- * This file is only imported by `settings/bs-config.ts`, which runs solely
30
- * under `npm run dev`. Nothing here ships to production.
31
- *
32
- * Terminal *and* file
33
- * -------------------
34
- * Printing to stdout only helps whoever owns that terminal. An AI agent working
35
- * in a separate session cannot see it, and starting a second `npm run dev` to
36
- * get its own copy would take different ports and orphan the browser tab. So
37
- * every event is also appended to `.casp/browser-log.jsonl`, which any process
38
- * can read. `npm run logs` renders it; see `settings/browser-log.py`.
39
- *
40
- * The log records successful page loads, not just failures. That is what makes
41
- * it trustworthy: without `load` events, a fixed error still sits in the file
42
- * forever (a clean reload writes nothing), and an empty file cannot be told
43
- * apart from "nobody opened the page". With them, a route's state is whatever
44
- * happened after its most recent load.
45
- */
46
-
47
- import type { IncomingMessage, ServerResponse } from "http";
48
- import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
49
- import { dirname, join } from "path";
50
- import { fileURLToPath } from "url";
51
- import chalk from "chalk";
52
-
53
- export const DEV_LOG_PATH = "/__pp-devlog";
54
- export const DEV_LOG_CLIENT_PATH = "/__pp-devlog.js";
55
-
56
- const SETTINGS_DIR = dirname(fileURLToPath(import.meta.url));
57
-
58
- /**
59
- * Session-scoped browser event log.
60
- *
61
- * Lives in `.casp/` because `settings/project-name.ts` deletes that directory at
62
- * the start of every `npm run dev`, so the log is truncated per dev session with
63
- * no cleanup code of its own -- and `.casp/` is already gitignored.
64
- */
65
- export const BROWSER_LOG_FILE = join(SETTINGS_DIR, "..", ".casp", "browser-log.jsonl");
66
-
67
- /**
68
- * Backstop cap. Compaction on every source change is what normally keeps the
69
- * file small; this only catches a pathological burst between two compactions.
70
- * Deliberately modest: at ~4 bytes per token, 256 KB is still ~64k tokens if
71
- * anything ever reads the raw file, and nothing should need more than that.
72
- */
73
- const MAX_LOG_BYTES = 256 * 1024;
74
- /** Events kept when trimming (the session header is always preserved). */
75
- const TRIM_KEEP_EVENTS = 2000;
76
-
77
- /**
78
- * An error reported within this long of its page load is treated as mount-phase.
79
- *
80
- * The distinction decides whether a clean reload is evidence of a fix. Mount
81
- * errors re-fire on every load, so a reload genuinely re-tests them. An error
82
- * from a click handler fires only when someone clicks, so a reload proves
83
- * nothing about it -- and reporting that route as CLEAN is a lie that sends
84
- * whoever reads it away from a live bug.
85
- */
86
- const MOUNT_PHASE_MS = 2000;
87
-
88
- /** Cap a single forwarded payload so a runaway logger cannot flood the terminal. */
89
- const MAX_BODY_BYTES = 64 * 1024;
90
- /** Identical repeated messages inside this window print once with a count. */
91
- const DEDUPE_WINDOW_MS = 1000;
92
-
93
- type ClientLogLevel = "error" | "warn" | "log";
94
-
95
- type ClientLogEntry = {
96
- /** `load` marks a page render; anything else is forwarded console output. */
97
- type?: "load" | "console";
98
- level?: ClientLogLevel;
99
- message?: string;
100
- url?: string;
101
- stack?: string;
102
- /** Per-page-load id, so an error can be tied to the load that produced it. */
103
- page?: string;
104
- };
105
-
106
- /** One line of `.casp/browser-log.jsonl`. Keep in sync with `browser_log.py`. */
107
- type LogEvent = {
108
- t: string;
109
- type: "session" | "session-end" | "load" | "error" | "warn" | "resolved" | "restart";
110
- route?: string;
111
- page?: string;
112
- message?: string;
113
- stack?: string[];
114
- pid?: number;
115
- port?: number;
116
- /** `resolved` only: how many earlier errors this clean load supersedes. */
117
- supersedes?: number;
118
- /** `session` only: tells anyone reading the raw file how to read it. */
119
- readme?: string;
120
- /** `error` only: whether a reload can re-test this. See MOUNT_PHASE_MS. */
121
- phase?: "mount" | "interaction" | "unknown";
122
- /** `error` only: milliseconds between the page load and the report. */
123
- afterMs?: number;
124
- /** Survived a compaction: it predates the current code and was not re-tested. */
125
- carried?: boolean;
126
- };
127
-
128
- const recentMessages = new Map<string, { at: number; count: number }>();
129
-
130
- /**
131
- * Errors that are still standing, per route.
132
- *
133
- * This is the working set compaction preserves, so it must hold whole events
134
- * rather than counts. A load removes the route's mount-phase errors (that load
135
- * re-tested them); interaction errors stay, because nothing about a reload
136
- * exercises a click handler.
137
- *
138
- * In-memory state is safe here precisely because this process and the log file
139
- * have the same lifetime: the Python server restarts on every `.py` edit, but
140
- * BrowserSync (this module) does not, and the log is only reset by this module.
141
- */
142
- const openErrors = new Map<string, LogEvent[]>();
143
-
144
- /** Load time per page id, so an error can be placed in mount or interaction phase. */
145
- const pageLoads = new Map<string, number>();
146
-
147
- /** The session line, replayed as the header every time the log is compacted. */
148
- let sessionHeader: LogEvent | null = null;
149
-
150
- let logFileUsable = true;
151
-
152
- /** Drop old events once the file grows past the cap, keeping the session header. */
153
- function trimLogFile(): void {
154
- try {
155
- const lines = readFileSync(BROWSER_LOG_FILE, "utf-8").split("\n").filter(Boolean);
156
- const header = lines.find((line) => line.includes('"type":"session"'));
157
- const tail = lines.slice(-TRIM_KEEP_EVENTS);
158
- const kept = header && !tail.includes(header) ? [header, ...tail] : tail;
159
- writeFileSync(BROWSER_LOG_FILE, kept.join("\n") + "\n", "utf-8");
160
- } catch {
161
- // A trim failure is not worth taking the dev server down for.
162
- }
163
- }
164
-
165
- /**
166
- * Append one event to the session log.
167
- *
168
- * Synchronous on purpose: `appendFileSync` cannot interleave partial lines the
169
- * way concurrent async writes could, and dev-time volume is trivial. A logging
170
- * failure must never break the dev server, so every error is swallowed once and
171
- * the sink then disables itself.
172
- */
173
- function appendEvent(event: LogEvent): void {
174
- if (!logFileUsable) return;
175
- try {
176
- let size = 0;
177
- try {
178
- size = statSync(BROWSER_LOG_FILE).size;
179
- } catch {
180
- mkdirSync(dirname(BROWSER_LOG_FILE), { recursive: true });
181
- }
182
- if (size > MAX_LOG_BYTES) trimLogFile();
183
- appendFileSync(BROWSER_LOG_FILE, JSON.stringify(event) + "\n", "utf-8");
184
- } catch {
185
- logFileUsable = false;
186
- }
187
- }
188
-
189
- /**
190
- * Open a new session log. Called by `bs-config.ts` once ports are settled.
191
- *
192
- * The header lets a reader decide whether the log is live or a leftover from a
193
- * dev server that has since exited -- the difference between "the app is clean"
194
- * and "nothing has been observed", which an agent must not confuse.
195
- */
196
- export function startBrowserLogSession(port: number): void {
197
- openErrors.clear();
198
- pageLoads.clear();
199
- sessionHeader = {
200
- t: new Date().toISOString(),
201
- type: "session",
202
- pid: process.pid,
203
- port,
204
- // A reader who opens this file directly (an AI agent will) has no way to tell
205
- // a fixed error from a live one. Say so on line one rather than relying on
206
- // everyone knowing the format.
207
- readme:
208
- "History, not current state. An error is superseded by a later 'load' or " +
209
- "'resolved' for the same route -- except an error with phase 'interaction', " +
210
- "which a reload cannot re-test. Run `npm run logs` for current status.",
211
- };
212
- appendEvent(sessionHeader);
213
- }
214
-
215
- /**
216
- * Rewrite the log down to what is still open, and mark the code boundary.
217
- *
218
- * Called on every source change. Two problems it solves at once: the file would
219
- * otherwise grow for the whole life of a dev session that is never restarted,
220
- * and errors produced by code that has since been edited would keep reading as
221
- * current.
222
- *
223
- * What survives is deliberately narrow -- the session header, a `restart` marker,
224
- * and errors nobody has re-tested. Resolved history is dropped outright; it has
225
- * already done its job. Anything carried once is dropped on the next compaction,
226
- * so a stale interaction error cannot haunt the log forever.
227
- */
228
- export function compactBrowserLog(reason: string): void {
229
- if (!logFileUsable || !sessionHeader) return;
230
-
231
- const carried: LogEvent[] = [];
232
- for (const [route, events] of [...openErrors]) {
233
- // Already carried once: the code has changed twice since, so stop reporting it.
234
- const survivors = events.filter((event) => !event.carried);
235
- if (survivors.length === 0) {
236
- openErrors.delete(route);
237
- continue;
238
- }
239
- const marked = survivors.map((event) => ({ ...event, carried: true }));
240
- openErrors.set(route, marked);
241
- carried.push(...marked);
242
- }
243
-
244
- // Page ids no longer resolve to a load event in the file; carried errors are
245
- // read by route instead, so drop the lookup table with them.
246
- pageLoads.clear();
247
-
248
- const restart: LogEvent = {
249
- t: new Date().toISOString(),
250
- type: "restart",
251
- message: `Source changed (${reason}); log compacted. Errors below predate the current code.`,
252
- supersedes: carried.length,
253
- };
254
-
255
- try {
256
- mkdirSync(dirname(BROWSER_LOG_FILE), { recursive: true });
257
- const lines = [sessionHeader, restart, ...carried]
258
- .map((event) => JSON.stringify(event))
259
- .join("\n");
260
- writeFileSync(BROWSER_LOG_FILE, lines + "\n", "utf-8");
261
- } catch {
262
- logFileUsable = false;
263
- }
264
- }
265
-
266
- /** Mark a clean shutdown so a reader knows the log is finished, not abandoned. */
267
- export function endBrowserLogSession(): void {
268
- appendEvent({ t: new Date().toISOString(), type: "session-end" });
269
- }
270
-
271
- function shouldPrint(signature: string): { print: boolean; repeated: number } {
272
- const now = Date.now();
273
- const previous = recentMessages.get(signature);
274
-
275
- if (previous && now - previous.at < DEDUPE_WINDOW_MS) {
276
- previous.count += 1;
277
- previous.at = now;
278
- return { print: false, repeated: previous.count };
279
- }
280
-
281
- const repeated = previous?.count ?? 0;
282
- recentMessages.set(signature, { at: now, count: 1 });
283
-
284
- // Keep the map from growing without bound during a long dev session.
285
- if (recentMessages.size > 200) {
286
- const cutoff = now - DEDUPE_WINDOW_MS * 10;
287
- for (const [key, value] of recentMessages) {
288
- if (value.at < cutoff) recentMessages.delete(key);
289
- }
290
- }
291
-
292
- return { print: true, repeated };
293
- }
294
-
295
- function formatRoute(rawUrl: string | undefined): string {
296
- if (!rawUrl) return "";
297
- try {
298
- const parsed = new URL(rawUrl);
299
- return parsed.pathname + parsed.search;
300
- } catch {
301
- return rawUrl;
302
- }
303
- }
304
-
305
- /** Keep the frames that name the failing template, not the whole runtime stack. */
306
- function topFrames(stack: string | undefined, limit: number): string[] {
307
- if (!stack) return [];
308
- return stack
309
- .split("\n")
310
- .map((line) => line.trim())
311
- .filter((line) => line.startsWith("at "))
312
- .slice(0, limit);
313
- }
314
-
315
- function handleEntry(entry: ClientLogEntry): void {
316
- const route = formatRoute(entry.url);
317
-
318
- // A page load is a log-only event: it is the "this route rendered" marker the
319
- // reader needs to date errors against, but printing one per navigation would
320
- // bury the errors it exists to contextualise.
321
- if (entry.type === "load") {
322
- const page = entry.page ?? "";
323
- appendEvent({ t: new Date().toISOString(), type: "load", route, page });
324
-
325
- pageLoads.set(page, Date.now());
326
- if (pageLoads.size > 200) {
327
- // Long sessions accumulate page ids; the oldest can no longer receive
328
- // reports, so drop them rather than growing without bound.
329
- for (const key of [...pageLoads.keys()].slice(0, 100)) pageLoads.delete(key);
330
- }
331
-
332
- // This load re-ran mount, so any mount error from an earlier page has been
333
- // retested. Interaction errors have not: nothing here clicked anything.
334
- const standing = openErrors.get(route);
335
- if (standing) {
336
- const retested = standing.filter((event) => event.page !== page && event.phase !== "interaction");
337
- const remaining = standing.filter((event) => !retested.includes(event));
338
- if (retested.length > 0) {
339
- if (remaining.length > 0) openErrors.set(route, remaining);
340
- else openErrors.delete(route);
341
- // A clean reload would otherwise write nothing, leaving the error as the
342
- // last word on this route in the raw file. State the supersession.
343
- appendEvent({
344
- t: new Date().toISOString(),
345
- type: "resolved",
346
- route,
347
- page,
348
- supersedes: retested.length,
349
- message: `Route reloaded; ${retested.length} earlier mount error(s) on this route are historical.`,
350
- });
351
- }
352
- }
353
- return;
354
- }
355
-
356
- const level: ClientLogLevel = entry.level ?? "error";
357
- const message = (entry.message ?? "").trim();
358
- if (!message) return;
359
-
360
- // The file keeps every occurrence; only the terminal is deduplicated, so a
361
- // burst stays readable there without the log losing the true error count.
362
- const event: LogEvent = {
363
- t: new Date().toISOString(),
364
- type: level === "warn" ? "warn" : "error",
365
- route,
366
- page: entry.page,
367
- message,
368
- stack: topFrames(entry.stack, 5),
369
- };
370
-
371
- if (level === "error") {
372
- // Timing is the only signal available for whether a reload can re-test this:
373
- // mount work finishes in well under a second, so a report arriving much later
374
- // came from something a person did.
375
- const loadedAt = entry.page ? pageLoads.get(entry.page) : undefined;
376
- if (loadedAt === undefined) {
377
- event.phase = "unknown";
378
- } else {
379
- event.afterMs = Date.now() - loadedAt;
380
- event.phase = event.afterMs <= MOUNT_PHASE_MS ? "mount" : "interaction";
381
- }
382
- openErrors.set(route, [...(openErrors.get(route) ?? []), event]);
383
- }
384
-
385
- if (level === "error" || level === "warn") {
386
- appendEvent(event);
387
- }
388
-
389
- printEntry(entry, level, message, route);
390
- }
391
-
392
- function printEntry(
393
- entry: ClientLogEntry,
394
- level: ClientLogLevel,
395
- message: string,
396
- route: string,
397
- ): void {
398
- const { print, repeated } = shouldPrint(`${level}:${message}`);
399
- if (!print) return;
400
-
401
- const badge =
402
- level === "error"
403
- ? chalk.bgRed.black.bold(" BROWSER ERROR ")
404
- : level === "warn"
405
- ? chalk.bgYellow.black.bold(" BROWSER WARN ")
406
- : chalk.bgBlue.black.bold(" BROWSER LOG ");
407
-
408
- const suppressed =
409
- repeated > 1 ? chalk.gray(` (${repeated} identical suppressed)`) : "";
410
-
411
- console.log("");
412
- console.log(`${badge} ${route ? chalk.cyan(route) : ""}${suppressed}`);
413
-
414
- for (const line of message.split("\n")) {
415
- console.log(` ${level === "error" ? chalk.red(line) : chalk.yellow(line)}`);
416
- }
417
-
418
- // The first frames are the runtime's own internals; the useful location is
419
- // the compiled template expression, which the message already names.
420
- for (const frame of topFrames(entry.stack, 3)) {
421
- console.log(chalk.gray(` ${frame}`));
422
- }
423
- console.log("");
424
- }
425
-
426
- function readBody(req: IncomingMessage): Promise<string> {
427
- return new Promise((resolve) => {
428
- let size = 0;
429
- const chunks: Buffer[] = [];
430
-
431
- req.on("data", (chunk: Buffer) => {
432
- size += chunk.length;
433
- if (size > MAX_BODY_BYTES) {
434
- req.destroy();
435
- resolve("");
436
- return;
437
- }
438
- chunks.push(chunk);
439
- });
440
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
441
- req.on("error", () => resolve(""));
442
- });
443
- }
444
-
445
- /**
446
- * Client script served at `/__pp-devlog.js`.
447
- *
448
- * Forwards only PulsePoint-prefixed console output plus genuine uncaught
449
- * errors, so ordinary `console.log` debugging stays in the browser where it
450
- * belongs and the terminal keeps signal high.
451
- */
452
- const CLIENT_SCRIPT = `(() => {
453
- if (window.__ppDevLogInstalled) return;
454
- window.__ppDevLogInstalled = true;
455
-
456
- var ENDPOINT = ${JSON.stringify(DEV_LOG_PATH)};
457
- var PP_PREFIX = /^\\[PP-(ERROR|WARN)\\]/;
458
-
459
- // Identifies this page load. Two POSTs can arrive out of order, so the reader
460
- // groups an error with its load by id rather than by arrival time -- that is
461
- // what makes "did this route reload clean?" answerable.
462
- var PAGE_ID = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
463
-
464
- function post(payload) {
465
- try {
466
- payload.url = location.href;
467
- payload.page = PAGE_ID;
468
- var body = JSON.stringify(payload);
469
- // keepalive lets the report survive a navigation triggered by the error.
470
- fetch(ENDPOINT, {
471
- method: "POST",
472
- headers: { "Content-Type": "application/json" },
473
- body: body,
474
- keepalive: true
475
- }).catch(function () {});
476
- } catch (e) {}
477
- }
478
-
479
- function send(level, message, stack) {
480
- post({
481
- type: "console",
482
- level: level,
483
- message: String(message).slice(0, 8000),
484
- stack: stack ? String(stack).slice(0, 4000) : undefined
485
- });
486
- }
487
-
488
- function format(args) {
489
- return Array.prototype.map
490
- .call(args, function (arg) {
491
- if (arg instanceof Error) return arg.message;
492
- if (typeof arg === "string") return arg;
493
- try {
494
- return JSON.stringify(arg);
495
- } catch (e) {
496
- return String(arg);
497
- }
498
- })
499
- .join(" ");
500
- }
501
-
502
- function hook(name, level) {
503
- var original = console[name];
504
- console[name] = function () {
505
- var text = format(arguments);
506
- // Only PulsePoint runtime diagnostics are forwarded; app console noise
507
- // stays in the browser.
508
- if (PP_PREFIX.test(text)) {
509
- var stack;
510
- for (var i = 0; i < arguments.length; i++) {
511
- if (arguments[i] instanceof Error) {
512
- stack = arguments[i].stack;
513
- break;
514
- }
515
- }
516
- send(level, text, stack);
517
- }
518
- return original.apply(console, arguments);
519
- };
520
- }
521
-
522
- hook("error", "error");
523
- hook("warn", "warn");
524
-
525
- // Announce the load before anything can fail, so even a mount-time error has
526
- // a page record to attach to.
527
- post({ type: "load" });
528
-
529
- window.addEventListener("error", function (event) {
530
- if (!event) return;
531
- var error = event.error;
532
- send(
533
- "error",
534
- "Uncaught " + (error && error.message ? error.message : event.message),
535
- error && error.stack
536
- );
537
- });
538
-
539
- window.addEventListener("unhandledrejection", function (event) {
540
- var reason = event && event.reason;
541
- send(
542
- "error",
543
- "Unhandled promise rejection: " +
544
- (reason && reason.message ? reason.message : String(reason)),
545
- reason && reason.stack
546
- );
547
- });
548
- })();`;
549
-
550
- /**
551
- * BrowserSync middleware pair: serves the client hook and receives its reports.
552
- *
553
- * Returned as a connect-style middleware; anything that is not one of the two
554
- * dev-log paths falls straight through to the proxy.
555
- */
556
- export function devLogMiddleware(
557
- req: IncomingMessage,
558
- res: ServerResponse,
559
- next: () => void,
560
- ): void {
561
- const url = (req.url || "").split("?")[0];
562
-
563
- if (url === DEV_LOG_CLIENT_PATH) {
564
- res.writeHead(200, {
565
- "Content-Type": "application/javascript; charset=utf-8",
566
- "Cache-Control": "no-store",
567
- });
568
- res.end(CLIENT_SCRIPT);
569
- return;
570
- }
571
-
572
- if (url === DEV_LOG_PATH && req.method === "POST") {
573
- void readBody(req).then((raw) => {
574
- try {
575
- if (raw) handleEntry(JSON.parse(raw) as ClientLogEntry);
576
- } catch {
577
- // A malformed report must never take the dev server down.
578
- }
579
- res.writeHead(204).end();
580
- });
581
- return;
582
- }
583
-
584
- next();
585
- }
1
+ /**
2
+ * Browser console -> dev terminal bridge (development only).
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * PulsePoint reports every runtime problem through `console.error` with a
7
+ * `[PP-ERROR]` / `[PP-WARN]` prefix. Those never reached the `npm run dev`
8
+ * terminal, so a broken route looked identical to a working one unless someone
9
+ * happened to have DevTools open. An agent editing templates had no feedback
10
+ * signal at all.
11
+ *
12
+ * This wires the browser back to the terminal that is already running:
13
+ * BrowserSync proxies every request, so a middleware on `POST /__pp-devlog`
14
+ * costs no extra port.
15
+ *
16
+ * The `<script src="/__pp-devlog.js">` tag is added by `_inject_dev_console_bridge`
17
+ * in `main.py`, gated on `CASPIAN_BROWSER_SYNC_PORT` -- the variable
18
+ * `settings/python-server.ts` sets only when the dev stack spawns the server.
19
+ * BrowserSync's own `snippetOptions` injection does not fire against this app's
20
+ * proxied responses (its client snippet is absent from them entirely), so the
21
+ * tag has to come from the render pipeline rather than from the proxy.
22
+ *
23
+ * Transport choice: plain HTTP, not the BrowserSync socket. The errors worth
24
+ * catching fire during `pp.mount()` at page load, which is often *before* the
25
+ * BrowserSync client socket finishes connecting -- a socket bridge would drop
26
+ * exactly the messages this exists to surface. A POST works from the first
27
+ * millisecond of page life.
28
+ *
29
+ * This file is only imported by `settings/bs-config.ts`, which runs solely
30
+ * under `npm run dev`. Nothing here ships to production.
31
+ *
32
+ * Terminal *and* file
33
+ * -------------------
34
+ * Printing to stdout only helps whoever owns that terminal. An AI agent working
35
+ * in a separate session cannot see it, and starting a second `npm run dev` to
36
+ * get its own copy would take different ports and orphan the browser tab. So
37
+ * every event is also appended to `.casp/browser-log.jsonl`, which any process
38
+ * can read. `npm run logs` renders it; see `settings/browser-log.py`.
39
+ *
40
+ * The log records successful page loads, not just failures. That is what makes
41
+ * it trustworthy: without `load` events, a fixed error still sits in the file
42
+ * forever (a clean reload writes nothing), and an empty file cannot be told
43
+ * apart from "nobody opened the page". With them, a route's state is whatever
44
+ * happened after its most recent load.
45
+ */
46
+
47
+ import type { IncomingMessage, ServerResponse } from "http";
48
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
49
+ import { dirname, join } from "path";
50
+ import { fileURLToPath } from "url";
51
+ import chalk from "chalk";
52
+
53
+ export const DEV_LOG_PATH = "/__pp-devlog";
54
+ export const DEV_LOG_CLIENT_PATH = "/__pp-devlog.js";
55
+
56
+ const SETTINGS_DIR = dirname(fileURLToPath(import.meta.url));
57
+
58
+ /**
59
+ * Session-scoped browser event log.
60
+ *
61
+ * Lives in `.casp/` because `settings/project-name.ts` deletes that directory at
62
+ * the start of every `npm run dev`, so the log is truncated per dev session with
63
+ * no cleanup code of its own -- and `.casp/` is already gitignored.
64
+ */
65
+ export const BROWSER_LOG_FILE = join(SETTINGS_DIR, "..", ".casp", "browser-log.jsonl");
66
+
67
+ /**
68
+ * Backstop cap. Compaction on every source change is what normally keeps the
69
+ * file small; this only catches a pathological burst between two compactions.
70
+ * Deliberately modest: at ~4 bytes per token, 256 KB is still ~64k tokens if
71
+ * anything ever reads the raw file, and nothing should need more than that.
72
+ */
73
+ const MAX_LOG_BYTES = 256 * 1024;
74
+ /** Events kept when trimming (the session header is always preserved). */
75
+ const TRIM_KEEP_EVENTS = 2000;
76
+
77
+ /**
78
+ * An error reported within this long of its page load is treated as mount-phase.
79
+ *
80
+ * The distinction decides whether a clean reload is evidence of a fix. Mount
81
+ * errors re-fire on every load, so a reload genuinely re-tests them. An error
82
+ * from a click handler fires only when someone clicks, so a reload proves
83
+ * nothing about it -- and reporting that route as CLEAN is a lie that sends
84
+ * whoever reads it away from a live bug.
85
+ */
86
+ const MOUNT_PHASE_MS = 2000;
87
+
88
+ /** Cap a single forwarded payload so a runaway logger cannot flood the terminal. */
89
+ const MAX_BODY_BYTES = 64 * 1024;
90
+ /** Identical repeated messages inside this window print once with a count. */
91
+ const DEDUPE_WINDOW_MS = 1000;
92
+
93
+ type ClientLogLevel = "error" | "warn" | "log";
94
+
95
+ type ClientLogEntry = {
96
+ /** `load` marks a page render; anything else is forwarded console output. */
97
+ type?: "load" | "console";
98
+ level?: ClientLogLevel;
99
+ message?: string;
100
+ url?: string;
101
+ stack?: string;
102
+ /** Per-page-load id, so an error can be tied to the load that produced it. */
103
+ page?: string;
104
+ };
105
+
106
+ /** One line of `.casp/browser-log.jsonl`. Keep in sync with `browser_log.py`. */
107
+ type LogEvent = {
108
+ t: string;
109
+ type: "session" | "session-end" | "load" | "error" | "warn" | "resolved" | "restart";
110
+ route?: string;
111
+ page?: string;
112
+ message?: string;
113
+ stack?: string[];
114
+ pid?: number;
115
+ port?: number;
116
+ /** `resolved` only: how many earlier errors this clean load supersedes. */
117
+ supersedes?: number;
118
+ /** `session` only: tells anyone reading the raw file how to read it. */
119
+ readme?: string;
120
+ /** `error` only: whether a reload can re-test this. See MOUNT_PHASE_MS. */
121
+ phase?: "mount" | "interaction" | "unknown";
122
+ /** `error` only: milliseconds between the page load and the report. */
123
+ afterMs?: number;
124
+ /** Survived a compaction: it predates the current code and was not re-tested. */
125
+ carried?: boolean;
126
+ };
127
+
128
+ const recentMessages = new Map<string, { at: number; count: number }>();
129
+
130
+ /**
131
+ * Errors that are still standing, per route.
132
+ *
133
+ * This is the working set compaction preserves, so it must hold whole events
134
+ * rather than counts. A load removes the route's mount-phase errors (that load
135
+ * re-tested them); interaction errors stay, because nothing about a reload
136
+ * exercises a click handler.
137
+ *
138
+ * In-memory state is safe here precisely because this process and the log file
139
+ * have the same lifetime: the Python server restarts on every `.py` edit, but
140
+ * BrowserSync (this module) does not, and the log is only reset by this module.
141
+ */
142
+ const openErrors = new Map<string, LogEvent[]>();
143
+
144
+ /** Load time per page id, so an error can be placed in mount or interaction phase. */
145
+ const pageLoads = new Map<string, number>();
146
+
147
+ /** The session line, replayed as the header every time the log is compacted. */
148
+ let sessionHeader: LogEvent | null = null;
149
+
150
+ let logFileUsable = true;
151
+
152
+ /** Drop old events once the file grows past the cap, keeping the session header. */
153
+ function trimLogFile(): void {
154
+ try {
155
+ const lines = readFileSync(BROWSER_LOG_FILE, "utf-8").split("\n").filter(Boolean);
156
+ const header = lines.find((line) => line.includes('"type":"session"'));
157
+ const tail = lines.slice(-TRIM_KEEP_EVENTS);
158
+ const kept = header && !tail.includes(header) ? [header, ...tail] : tail;
159
+ writeFileSync(BROWSER_LOG_FILE, kept.join("\n") + "\n", "utf-8");
160
+ } catch {
161
+ // A trim failure is not worth taking the dev server down for.
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Append one event to the session log.
167
+ *
168
+ * Synchronous on purpose: `appendFileSync` cannot interleave partial lines the
169
+ * way concurrent async writes could, and dev-time volume is trivial. A logging
170
+ * failure must never break the dev server, so every error is swallowed once and
171
+ * the sink then disables itself.
172
+ */
173
+ function appendEvent(event: LogEvent): void {
174
+ if (!logFileUsable) return;
175
+ try {
176
+ let size = 0;
177
+ try {
178
+ size = statSync(BROWSER_LOG_FILE).size;
179
+ } catch {
180
+ mkdirSync(dirname(BROWSER_LOG_FILE), { recursive: true });
181
+ }
182
+ if (size > MAX_LOG_BYTES) trimLogFile();
183
+ appendFileSync(BROWSER_LOG_FILE, JSON.stringify(event) + "\n", "utf-8");
184
+ } catch {
185
+ logFileUsable = false;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Open a new session log. Called by `bs-config.ts` once ports are settled.
191
+ *
192
+ * The header lets a reader decide whether the log is live or a leftover from a
193
+ * dev server that has since exited -- the difference between "the app is clean"
194
+ * and "nothing has been observed", which an agent must not confuse.
195
+ */
196
+ export function startBrowserLogSession(port: number): void {
197
+ openErrors.clear();
198
+ pageLoads.clear();
199
+ sessionHeader = {
200
+ t: new Date().toISOString(),
201
+ type: "session",
202
+ pid: process.pid,
203
+ port,
204
+ // A reader who opens this file directly (an AI agent will) has no way to tell
205
+ // a fixed error from a live one. Say so on line one rather than relying on
206
+ // everyone knowing the format.
207
+ readme:
208
+ "History, not current state. An error is superseded by a later 'load' or " +
209
+ "'resolved' for the same route -- except an error with phase 'interaction', " +
210
+ "which a reload cannot re-test. Run `npm run logs` for current status.",
211
+ };
212
+ appendEvent(sessionHeader);
213
+ }
214
+
215
+ /**
216
+ * Rewrite the log down to what is still open, and mark the code boundary.
217
+ *
218
+ * Called on every source change. Two problems it solves at once: the file would
219
+ * otherwise grow for the whole life of a dev session that is never restarted,
220
+ * and errors produced by code that has since been edited would keep reading as
221
+ * current.
222
+ *
223
+ * What survives is deliberately narrow -- the session header, a `restart` marker,
224
+ * and errors nobody has re-tested. Resolved history is dropped outright; it has
225
+ * already done its job. Anything carried once is dropped on the next compaction,
226
+ * so a stale interaction error cannot haunt the log forever.
227
+ */
228
+ export function compactBrowserLog(reason: string): void {
229
+ if (!logFileUsable || !sessionHeader) return;
230
+
231
+ const carried: LogEvent[] = [];
232
+ for (const [route, events] of [...openErrors]) {
233
+ // Already carried once: the code has changed twice since, so stop reporting it.
234
+ const survivors = events.filter((event) => !event.carried);
235
+ if (survivors.length === 0) {
236
+ openErrors.delete(route);
237
+ continue;
238
+ }
239
+ const marked = survivors.map((event) => ({ ...event, carried: true }));
240
+ openErrors.set(route, marked);
241
+ carried.push(...marked);
242
+ }
243
+
244
+ // Page ids no longer resolve to a load event in the file; carried errors are
245
+ // read by route instead, so drop the lookup table with them.
246
+ pageLoads.clear();
247
+
248
+ const restart: LogEvent = {
249
+ t: new Date().toISOString(),
250
+ type: "restart",
251
+ message: `Source changed (${reason}); log compacted. Errors below predate the current code.`,
252
+ supersedes: carried.length,
253
+ };
254
+
255
+ try {
256
+ mkdirSync(dirname(BROWSER_LOG_FILE), { recursive: true });
257
+ const lines = [sessionHeader, restart, ...carried]
258
+ .map((event) => JSON.stringify(event))
259
+ .join("\n");
260
+ writeFileSync(BROWSER_LOG_FILE, lines + "\n", "utf-8");
261
+ } catch {
262
+ logFileUsable = false;
263
+ }
264
+ }
265
+
266
+ /** Mark a clean shutdown so a reader knows the log is finished, not abandoned. */
267
+ export function endBrowserLogSession(): void {
268
+ appendEvent({ t: new Date().toISOString(), type: "session-end" });
269
+ }
270
+
271
+ function shouldPrint(signature: string): { print: boolean; repeated: number } {
272
+ const now = Date.now();
273
+ const previous = recentMessages.get(signature);
274
+
275
+ if (previous && now - previous.at < DEDUPE_WINDOW_MS) {
276
+ previous.count += 1;
277
+ previous.at = now;
278
+ return { print: false, repeated: previous.count };
279
+ }
280
+
281
+ const repeated = previous?.count ?? 0;
282
+ recentMessages.set(signature, { at: now, count: 1 });
283
+
284
+ // Keep the map from growing without bound during a long dev session.
285
+ if (recentMessages.size > 200) {
286
+ const cutoff = now - DEDUPE_WINDOW_MS * 10;
287
+ for (const [key, value] of recentMessages) {
288
+ if (value.at < cutoff) recentMessages.delete(key);
289
+ }
290
+ }
291
+
292
+ return { print: true, repeated };
293
+ }
294
+
295
+ function formatRoute(rawUrl: string | undefined): string {
296
+ if (!rawUrl) return "";
297
+ try {
298
+ const parsed = new URL(rawUrl);
299
+ return parsed.pathname + parsed.search;
300
+ } catch {
301
+ return rawUrl;
302
+ }
303
+ }
304
+
305
+ /** Keep the frames that name the failing template, not the whole runtime stack. */
306
+ function topFrames(stack: string | undefined, limit: number): string[] {
307
+ if (!stack) return [];
308
+ return stack
309
+ .split("\n")
310
+ .map((line) => line.trim())
311
+ .filter((line) => line.startsWith("at "))
312
+ .slice(0, limit);
313
+ }
314
+
315
+ function handleEntry(entry: ClientLogEntry): void {
316
+ const route = formatRoute(entry.url);
317
+
318
+ // A page load is a log-only event: it is the "this route rendered" marker the
319
+ // reader needs to date errors against, but printing one per navigation would
320
+ // bury the errors it exists to contextualise.
321
+ if (entry.type === "load") {
322
+ const page = entry.page ?? "";
323
+ appendEvent({ t: new Date().toISOString(), type: "load", route, page });
324
+
325
+ pageLoads.set(page, Date.now());
326
+ if (pageLoads.size > 200) {
327
+ // Long sessions accumulate page ids; the oldest can no longer receive
328
+ // reports, so drop them rather than growing without bound.
329
+ for (const key of [...pageLoads.keys()].slice(0, 100)) pageLoads.delete(key);
330
+ }
331
+
332
+ // This load re-ran mount, so any mount error from an earlier page has been
333
+ // retested. Interaction errors have not: nothing here clicked anything.
334
+ const standing = openErrors.get(route);
335
+ if (standing) {
336
+ const retested = standing.filter((event) => event.page !== page && event.phase !== "interaction");
337
+ const remaining = standing.filter((event) => !retested.includes(event));
338
+ if (retested.length > 0) {
339
+ if (remaining.length > 0) openErrors.set(route, remaining);
340
+ else openErrors.delete(route);
341
+ // A clean reload would otherwise write nothing, leaving the error as the
342
+ // last word on this route in the raw file. State the supersession.
343
+ appendEvent({
344
+ t: new Date().toISOString(),
345
+ type: "resolved",
346
+ route,
347
+ page,
348
+ supersedes: retested.length,
349
+ message: `Route reloaded; ${retested.length} earlier mount error(s) on this route are historical.`,
350
+ });
351
+ }
352
+ }
353
+ return;
354
+ }
355
+
356
+ const level: ClientLogLevel = entry.level ?? "error";
357
+ const message = (entry.message ?? "").trim();
358
+ if (!message) return;
359
+
360
+ // The file keeps every occurrence; only the terminal is deduplicated, so a
361
+ // burst stays readable there without the log losing the true error count.
362
+ const event: LogEvent = {
363
+ t: new Date().toISOString(),
364
+ type: level === "warn" ? "warn" : "error",
365
+ route,
366
+ page: entry.page,
367
+ message,
368
+ stack: topFrames(entry.stack, 5),
369
+ };
370
+
371
+ if (level === "error") {
372
+ // Timing is the only signal available for whether a reload can re-test this:
373
+ // mount work finishes in well under a second, so a report arriving much later
374
+ // came from something a person did.
375
+ const loadedAt = entry.page ? pageLoads.get(entry.page) : undefined;
376
+ if (loadedAt === undefined) {
377
+ event.phase = "unknown";
378
+ } else {
379
+ event.afterMs = Date.now() - loadedAt;
380
+ event.phase = event.afterMs <= MOUNT_PHASE_MS ? "mount" : "interaction";
381
+ }
382
+ openErrors.set(route, [...(openErrors.get(route) ?? []), event]);
383
+ }
384
+
385
+ if (level === "error" || level === "warn") {
386
+ appendEvent(event);
387
+ }
388
+
389
+ printEntry(entry, level, message, route);
390
+ }
391
+
392
+ function printEntry(
393
+ entry: ClientLogEntry,
394
+ level: ClientLogLevel,
395
+ message: string,
396
+ route: string,
397
+ ): void {
398
+ const { print, repeated } = shouldPrint(`${level}:${message}`);
399
+ if (!print) return;
400
+
401
+ const badge =
402
+ level === "error"
403
+ ? chalk.bgRed.black.bold(" BROWSER ERROR ")
404
+ : level === "warn"
405
+ ? chalk.bgYellow.black.bold(" BROWSER WARN ")
406
+ : chalk.bgBlue.black.bold(" BROWSER LOG ");
407
+
408
+ const suppressed =
409
+ repeated > 1 ? chalk.gray(` (${repeated} identical suppressed)`) : "";
410
+
411
+ console.log("");
412
+ console.log(`${badge} ${route ? chalk.cyan(route) : ""}${suppressed}`);
413
+
414
+ for (const line of message.split("\n")) {
415
+ console.log(` ${level === "error" ? chalk.red(line) : chalk.yellow(line)}`);
416
+ }
417
+
418
+ // The first frames are the runtime's own internals; the useful location is
419
+ // the compiled template expression, which the message already names.
420
+ for (const frame of topFrames(entry.stack, 3)) {
421
+ console.log(chalk.gray(` ${frame}`));
422
+ }
423
+ console.log("");
424
+ }
425
+
426
+ function readBody(req: IncomingMessage): Promise<string> {
427
+ return new Promise((resolve) => {
428
+ let size = 0;
429
+ const chunks: Buffer[] = [];
430
+
431
+ req.on("data", (chunk: Buffer) => {
432
+ size += chunk.length;
433
+ if (size > MAX_BODY_BYTES) {
434
+ req.destroy();
435
+ resolve("");
436
+ return;
437
+ }
438
+ chunks.push(chunk);
439
+ });
440
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
441
+ req.on("error", () => resolve(""));
442
+ });
443
+ }
444
+
445
+ /**
446
+ * Client script served at `/__pp-devlog.js`.
447
+ *
448
+ * Forwards only PulsePoint-prefixed console output plus genuine uncaught
449
+ * errors, so ordinary `console.log` debugging stays in the browser where it
450
+ * belongs and the terminal keeps signal high.
451
+ */
452
+ const CLIENT_SCRIPT = `(() => {
453
+ if (window.__ppDevLogInstalled) return;
454
+ window.__ppDevLogInstalled = true;
455
+
456
+ var ENDPOINT = ${JSON.stringify(DEV_LOG_PATH)};
457
+ var PP_PREFIX = /^\\[PP-(ERROR|WARN)\\]/;
458
+
459
+ // Identifies this page load. Two POSTs can arrive out of order, so the reader
460
+ // groups an error with its load by id rather than by arrival time -- that is
461
+ // what makes "did this route reload clean?" answerable.
462
+ var PAGE_ID = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
463
+
464
+ function post(payload) {
465
+ try {
466
+ payload.url = location.href;
467
+ payload.page = PAGE_ID;
468
+ var body = JSON.stringify(payload);
469
+ // keepalive lets the report survive a navigation triggered by the error.
470
+ fetch(ENDPOINT, {
471
+ method: "POST",
472
+ headers: { "Content-Type": "application/json" },
473
+ body: body,
474
+ keepalive: true
475
+ }).catch(function () {});
476
+ } catch (e) {}
477
+ }
478
+
479
+ function send(level, message, stack) {
480
+ post({
481
+ type: "console",
482
+ level: level,
483
+ message: String(message).slice(0, 8000),
484
+ stack: stack ? String(stack).slice(0, 4000) : undefined
485
+ });
486
+ }
487
+
488
+ function format(args) {
489
+ return Array.prototype.map
490
+ .call(args, function (arg) {
491
+ if (arg instanceof Error) return arg.message;
492
+ if (typeof arg === "string") return arg;
493
+ try {
494
+ return JSON.stringify(arg);
495
+ } catch (e) {
496
+ return String(arg);
497
+ }
498
+ })
499
+ .join(" ");
500
+ }
501
+
502
+ function hook(name, level) {
503
+ var original = console[name];
504
+ console[name] = function () {
505
+ var text = format(arguments);
506
+ // Only PulsePoint runtime diagnostics are forwarded; app console noise
507
+ // stays in the browser.
508
+ if (PP_PREFIX.test(text)) {
509
+ var stack;
510
+ for (var i = 0; i < arguments.length; i++) {
511
+ if (arguments[i] instanceof Error) {
512
+ stack = arguments[i].stack;
513
+ break;
514
+ }
515
+ }
516
+ send(level, text, stack);
517
+ }
518
+ return original.apply(console, arguments);
519
+ };
520
+ }
521
+
522
+ hook("error", "error");
523
+ hook("warn", "warn");
524
+
525
+ // Announce the load before anything can fail, so even a mount-time error has
526
+ // a page record to attach to.
527
+ post({ type: "load" });
528
+
529
+ window.addEventListener("error", function (event) {
530
+ if (!event) return;
531
+ var error = event.error;
532
+ send(
533
+ "error",
534
+ "Uncaught " + (error && error.message ? error.message : event.message),
535
+ error && error.stack
536
+ );
537
+ });
538
+
539
+ window.addEventListener("unhandledrejection", function (event) {
540
+ var reason = event && event.reason;
541
+ send(
542
+ "error",
543
+ "Unhandled promise rejection: " +
544
+ (reason && reason.message ? reason.message : String(reason)),
545
+ reason && reason.stack
546
+ );
547
+ });
548
+ })();`;
549
+
550
+ /**
551
+ * BrowserSync middleware pair: serves the client hook and receives its reports.
552
+ *
553
+ * Returned as a connect-style middleware; anything that is not one of the two
554
+ * dev-log paths falls straight through to the proxy.
555
+ */
556
+ export function devLogMiddleware(
557
+ req: IncomingMessage,
558
+ res: ServerResponse,
559
+ next: () => void,
560
+ ): void {
561
+ const url = (req.url || "").split("?")[0];
562
+
563
+ if (url === DEV_LOG_CLIENT_PATH) {
564
+ res.writeHead(200, {
565
+ "Content-Type": "application/javascript; charset=utf-8",
566
+ "Cache-Control": "no-store",
567
+ });
568
+ res.end(CLIENT_SCRIPT);
569
+ return;
570
+ }
571
+
572
+ if (url === DEV_LOG_PATH && req.method === "POST") {
573
+ void readBody(req).then((raw) => {
574
+ try {
575
+ if (raw) handleEntry(JSON.parse(raw) as ClientLogEntry);
576
+ } catch {
577
+ // A malformed report must never take the dev server down.
578
+ }
579
+ res.writeHead(204).end();
580
+ });
581
+ return;
582
+ }
583
+
584
+ next();
585
+ }