@djangocfg/devtools 2.1.567 → 2.1.570

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/devtools",
3
- "version": "2.1.567",
3
+ "version": "2.1.570",
4
4
  "description": "Frontend devtools for django-cfg apps: error/console capture with backend ingest, plus a floating debug panel. One package, one event stream.",
5
5
  "keywords": [
6
6
  "django",
@@ -46,19 +46,21 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "check": "tsc --noEmit",
49
- "lint": "eslint ."
49
+ "lint": "eslint .",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest"
50
52
  },
51
53
  "peerDependencies": {
52
- "@djangocfg/ui-core": "^2.1.567",
54
+ "@djangocfg/ui-core": "^2.1.570",
53
55
  "lucide-react": ">=0.400.0",
54
56
  "react": "^19.0.0",
55
57
  "react-dom": "^19.0.0",
56
58
  "zustand": "^5.0.0"
57
59
  },
58
60
  "devDependencies": {
59
- "@djangocfg/eslint-config": "^2.1.567",
60
- "@djangocfg/typescript-config": "^2.1.567",
61
- "@djangocfg/ui-core": "^2.1.567",
61
+ "@djangocfg/eslint-config": "^2.1.570",
62
+ "@djangocfg/typescript-config": "^2.1.570",
63
+ "@djangocfg/ui-core": "^2.1.570",
62
64
  "@storybook/react-vite": "^10.5.0",
63
65
  "@types/node": "^25.9.5",
64
66
  "@types/react": "19.2.15",
@@ -66,6 +68,7 @@
66
68
  "lucide-react": "^0.545.0",
67
69
  "storybook": "^10.5.0",
68
70
  "typescript": "^5.9.3",
71
+ "vitest": "^4.1.10",
69
72
  "zustand": "^5.0.14"
70
73
  }
71
74
  }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { formatConsoleArgs } from '../capture';
4
+
5
+ /**
6
+ * What reaches the alert has to say what broke.
7
+ *
8
+ * consola styles its tag with `%c`, so one logger call arrives as
9
+ * ["%capi:auth%c message", "<css>", "", err]. Joined on a space, the
10
+ * stylesheet became the message and the truncation then cut the sentence that
11
+ * mattered — the Telegram alert read "background: #c0392b; border-radius…".
12
+ */
13
+ describe('formatConsoleArgs', () => {
14
+ describe('the production case', () => {
15
+ it('drops the stylesheet and keeps the sentence', () => {
16
+ const css = '\n background: #c0392b;\n border-radius: 0.5em;\n color: white;\n ';
17
+ const out = formatConsoleArgs([
18
+ '%capi:auth:error%c Verify OTP failed:',
19
+ css,
20
+ '',
21
+ new Error('HTTP 401: '),
22
+ ]);
23
+ // The trailing space is the Error's own message, not our formatting.
24
+ expect(out).toBe('api:auth:error Verify OTP failed: HTTP 401: ');
25
+ expect(out).not.toContain('background');
26
+ expect(out).not.toContain('%c');
27
+ });
28
+ });
29
+
30
+ describe('format specifiers', () => {
31
+ it('substitutes %s', () => {
32
+ expect(formatConsoleArgs(['hello %s', 'world'])).toBe('hello world');
33
+ });
34
+
35
+ it.each([
36
+ ['%d', 42.9, '42'],
37
+ ['%i', 42.9, '42'],
38
+ ['%f', 1.5, '1.5'],
39
+ ])('substitutes %s', (spec, input, expected) => {
40
+ expect(formatConsoleArgs([`n=${spec}`, input])).toBe(`n=${expected}`);
41
+ });
42
+
43
+ it('serialises %o and %j', () => {
44
+ expect(formatConsoleArgs(['%o', { a: 1 }])).toBe('{"a":1}');
45
+ expect(formatConsoleArgs(['%j', { a: 1 }])).toBe('{"a":1}');
46
+ });
47
+
48
+ it('treats %% as a literal percent and consumes no argument', () => {
49
+ expect(formatConsoleArgs(['100%% done %s', 'ok'])).toBe('100% done ok');
50
+ });
51
+
52
+ it('appends arguments the format string does not claim', () => {
53
+ /* What the browser does with extras — dropping them would lose the
54
+ error object, which is the only part worth reading. */
55
+ expect(formatConsoleArgs(['%s:', 'ctx', 'detail', 42])).toBe('ctx: detail 42');
56
+ });
57
+
58
+ it('keeps an unmatched specifier rather than inventing a value', () => {
59
+ expect(formatConsoleArgs(['a %s b %s', 'one'])).toBe('a one b %s');
60
+ });
61
+ });
62
+
63
+ describe('when there is nothing to substitute', () => {
64
+ it('joins plain arguments on a space, as before', () => {
65
+ expect(formatConsoleArgs(['failed:', 'because'])).toBe('failed: because');
66
+ });
67
+
68
+ it('leaves a non-string first argument alone', () => {
69
+ expect(formatConsoleArgs([{ a: 1 }, 'tail'])).toBe('{"a":1} tail');
70
+ });
71
+
72
+ it('uses an Error message, not its stringification', () => {
73
+ expect(formatConsoleArgs(['boom:', new Error('the reason')])).toBe('boom: the reason');
74
+ });
75
+
76
+ it('survives a value JSON cannot serialise', () => {
77
+ const cyclic: Record<string, unknown> = {};
78
+ cyclic.self = cyclic;
79
+ expect(() => formatConsoleArgs(['x', cyclic])).not.toThrow();
80
+ });
81
+ });
82
+
83
+ describe('edge cases that must not throw', () => {
84
+ it.each([[[]], [[undefined]], [[null]], [['%c']], [['%c', 'css']]])(
85
+ 'handles %j',
86
+ (args) => {
87
+ expect(() => formatConsoleArgs(args as unknown[])).not.toThrow();
88
+ },
89
+ );
90
+
91
+ it('produces nothing but the tag when the message is only a style', () => {
92
+ expect(formatConsoleArgs(['%ctag%c', 'css', ''])).toBe('tag');
93
+ });
94
+ });
95
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isStreamingRuntimeNoise } from '../capture';
4
+
5
+ /**
6
+ * React's inline streaming scripts fail only when something outside the app
7
+ * removed the hidden segment first. The stacks below are verbatim from
8
+ * carapis.com's monitor log, 2026-09-23.
9
+ */
10
+ describe('isStreamingRuntimeNoise', () => {
11
+ it('drops a $RS failure', () => {
12
+ const stack =
13
+ "TypeError: Cannot read properties of null (reading 'parentNode')\n" +
14
+ ' at $RS (https://carapis.com/catalog/genesis/eq900/c2d25771/:2:3330)\n' +
15
+ ' at https://carapis.com/catalog/genesis/eq900/c2d25771/:3:77605';
16
+ expect(isStreamingRuntimeNoise(stack)).toBe(true);
17
+ });
18
+
19
+ it('drops a $RC failure', () => {
20
+ expect(
21
+ isStreamingRuntimeNoise("TypeError: x\n at $RC (https://a.test/:2:10)\n at https://a.test/:9:1"),
22
+ ).toBe(true);
23
+ });
24
+
25
+ it('keeps an app error whose top frame is the app', () => {
26
+ const stack =
27
+ "TypeError: Cannot read properties of null (reading 'parentNode')\n" +
28
+ ' at Gallery (https://carapis.com/_next/static/chunks/abc.js:1:200)\n' +
29
+ ' at $RS (https://carapis.com/x/:2:3330)';
30
+ expect(isStreamingRuntimeNoise(stack)).toBe(false);
31
+ });
32
+
33
+ it('keeps an app function that merely starts with $R', () => {
34
+ expect(isStreamingRuntimeNoise('Error\n at $Router (https://a.test/app.js:1:1)')).toBe(false);
35
+ });
36
+
37
+ it('keeps an event with no stack', () => {
38
+ expect(isStreamingRuntimeNoise(undefined)).toBe(false);
39
+ expect(isStreamingRuntimeNoise('')).toBe(false);
40
+ });
41
+ });
package/src/capture.ts CHANGED
@@ -31,9 +31,31 @@ const LIBRARY_NOISE: RegExp[] = [
31
31
  /input-otp could not insert CSS rule/i,
32
32
  ]
33
33
 
34
+ // React's streaming-SSR runtime: the inline `$RS` (reveal segment), `$RC`
35
+ // (complete boundary) and kin that the server writes into the HTML to move
36
+ // each late chunk from its `<div hidden id="S:n">` into its placeholder.
37
+ // They throw only when that hidden element is already gone — something
38
+ // outside the app rewrote the document before the stream finished (a
39
+ // headless scraper stripping hidden nodes, a reader-mode or translation
40
+ // rewrite). The frame is React's, never the app's, and nothing on the page
41
+ // can fix it. Measured on carapis.com, 2026-09-23: 19,381 events in a day,
42
+ // 99.9% from one fixed "Chrome/145" user agent, each page load failing all
43
+ // 19 of its `$RS` calls at once — while a real browser on the same URL
44
+ // raised none.
45
+ const STREAMING_RUNTIME_FRAME = /^\s*at \$R[A-Z]{1,2} \(/m
46
+
47
+ export function isStreamingRuntimeNoise(stack?: string): boolean {
48
+ if (!stack) return false
49
+ // The TOP frame only: an app error that merely passed through a streamed
50
+ // chunk still has its own frame first, and must still be reported.
51
+ const firstFrame = stack.split('\n').find((line) => /^\s*at /.test(line))
52
+ return firstFrame !== undefined && STREAMING_RUNTIME_FRAME.test(firstFrame)
53
+ }
54
+
34
55
  function isNoise(msg: string, stack?: string): boolean {
35
56
  if (HYDRATION_NOISE.some((p) => p.test(msg))) return true
36
57
  if (LIBRARY_NOISE.some((p) => p.test(msg))) return true
58
+ if (isStreamingRuntimeNoise(stack)) return true
37
59
  // Our own transport failing must never loop back into capture.
38
60
  return INGEST_PATTERN.test(msg) || INGEST_PATTERN.test(stack ?? '')
39
61
  }
@@ -92,22 +114,64 @@ export function installJsErrorCapture(): () => void {
92
114
 
93
115
  // ─── console.warn / console.error ────────────────────────────────────────────
94
116
 
117
+ function stringifyOne(a: unknown): string {
118
+ let s: string
119
+ if (typeof a === 'string') s = a
120
+ else if (a instanceof Error) s = a.message
121
+ else {
122
+ try {
123
+ // `JSON.stringify(undefined)` returns undefined, not a string, and a
124
+ // function or a symbol does the same — `truncate` then throws on
125
+ // `.length` and the capture swallows the whole event.
126
+ s = JSON.stringify(a) ?? String(a)
127
+ } catch {
128
+ s = String(a)
129
+ }
130
+ }
131
+ return truncate(s, ARG_MAX)
132
+ }
133
+
134
+ /**
135
+ * Apply console format specifiers the way the browser does.
136
+ *
137
+ * consola styles its tag with `%c`, so a single logger call arrives here as
138
+ * ["%capi:auth%c Verify OTP failed:", "<css>", "", err]. Joining that on a
139
+ * space put the stylesheet INTO the message — the Telegram alert read
140
+ * "background: #c0392b; border-radius: 0.5em; ..." and the sentence that said
141
+ * what had failed was what the truncation then cut. Every styled log from
142
+ * consola, and anything else using %c, was unreadable for the same reason.
143
+ *
144
+ * Only the specifiers a console actually consumes are handled: `%c` swallows
145
+ * its argument and contributes nothing, `%s`/`%d`/`%i`/`%f`/`%o`/`%O`/`%j`
146
+ * substitute theirs. `%%` is a literal percent and consumes nothing. Anything
147
+ * left over is appended, which is what the browser does with extra arguments.
148
+ *
149
+ * Not applied when the first argument is not a string, or carries no
150
+ * specifier: then there is nothing to substitute and the old join is right.
151
+ */
152
+ export function formatConsoleArgs(args: unknown[]): string {
153
+ const [first, ...rest] = args
154
+ if (typeof first !== 'string' || !/%[csdifoOj%]/.test(first)) {
155
+ return args.map(stringifyOne).join(' ')
156
+ }
157
+
158
+ let i = 0
159
+ const out = first.replace(/%([csdifoOj%])/g, (match, spec: string) => {
160
+ if (spec === '%') return '%'
161
+ if (i >= rest.length) return match // no argument left: the browser keeps the token
162
+ const arg = rest[i++]
163
+ if (spec === 'c') return '' // a style, not content
164
+ if (spec === 'd' || spec === 'i') return String(Math.trunc(Number(arg)))
165
+ if (spec === 'f') return String(Number(arg))
166
+ return stringifyOne(arg)
167
+ })
168
+
169
+ const unused = rest.slice(i).map(stringifyOne)
170
+ return truncate([out.trim(), ...unused].filter(Boolean).join(' '), ARG_MAX * 4)
171
+ }
172
+
95
173
  function stringifyArgs(args: unknown[]): string {
96
- return args
97
- .map((a) => {
98
- let s: string
99
- if (typeof a === 'string') s = a
100
- else if (a instanceof Error) s = a.message
101
- else {
102
- try {
103
- s = JSON.stringify(a)
104
- } catch {
105
- s = String(a)
106
- }
107
- }
108
- return truncate(s, ARG_MAX)
109
- })
110
- .join(' ')
174
+ return formatConsoleArgs(args)
111
175
  }
112
176
 
113
177
  export function installConsoleCapture(): () => void {