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.
- package/dist/caspian.js +1 -1
- package/dist/index.js +1 -1
- package/dist/public/js/main.js +12 -12
- package/dist/settings/_component_imports.py +70 -70
- package/dist/settings/browser_log.py +498 -498
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/build-static.py +290 -290
- package/dist/settings/check.py +415 -415
- package/dist/settings/check_templates.py +274 -274
- package/dist/settings/component-map.ts +6 -6
- package/dist/settings/dev-log-bridge.ts +585 -585
- package/dist/settings/fix.py +95 -95
- package/dist/settings/python-server.ts +31 -31
- package/dist/settings/run-postcss.ts +317 -317
- package/dist/settings/serve-static.py +188 -188
- package/dist/tests/README.md +135 -135
- package/dist/tests/conftest.py +89 -89
- package/dist/tests/test_health_route.py +44 -44
- package/dist/tests/test_main_helpers.py +112 -112
- package/package.json +1 -1
|
@@ -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
|
+
}
|