@reliantlabs/forge-web-runtime 0.3.1 → 0.3.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/devlog.d.ts +10 -1
- package/dist/devlog.js +330 -23
- package/dist/devlog.js.map +1 -1
- package/dist/service-hooks.js +9 -0
- package/dist/service-hooks.js.map +1 -1
- package/package.json +16 -16
package/dist/devlog.d.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/** Console methods this module mirrors. */
|
|
2
2
|
declare const LEVELS: readonly ["log", "info", "warn", "error", "debug"];
|
|
3
3
|
type Level = (typeof LEVELS)[number];
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* One log line on the wire.
|
|
6
|
+
*
|
|
7
|
+
* Also the whole v1 body: a legacy endpoint receives exactly this object, which
|
|
8
|
+
* is why the shape is unchanged and still exported.
|
|
9
|
+
*/
|
|
5
10
|
export interface DevLogPayload {
|
|
6
11
|
level: Level | "error";
|
|
7
12
|
msg: string;
|
|
8
13
|
}
|
|
14
|
+
/** The v2 body: many lines, one request. */
|
|
15
|
+
export interface DevLogBatch {
|
|
16
|
+
entries: DevLogPayload[];
|
|
17
|
+
}
|
|
9
18
|
export interface DevLoggingOptions {
|
|
10
19
|
/**
|
|
11
20
|
* Whether to install at all. Pass `import.meta.env.DEV` (Vite) or
|
package/dist/devlog.js
CHANGED
|
@@ -8,6 +8,47 @@
|
|
|
8
8
|
// browser console output to a dev-server endpoint that prints it, so it lands
|
|
9
9
|
// in .forge/logs/<env>/frontend_<name>.log next to the server logs.
|
|
10
10
|
//
|
|
11
|
+
// ── Why this batches (protocol v2) ───────────────────────────────────────
|
|
12
|
+
//
|
|
13
|
+
// The first version sent one fetch POST per console call. Measured in a real
|
|
14
|
+
// app that logs freely, that is ~80 requests/s at light load and 200+/s in a
|
|
15
|
+
// burst, and those requests are not free bystanders: they share the browser's
|
|
16
|
+
// six-connections-per-origin pool and the dev server's single event loop with
|
|
17
|
+
// the app's own RPCs. In the same session, RPC latency averaged 37ms in quiet
|
|
18
|
+
// seconds and 127ms in seconds carrying 200+ log lines. Worse, `keepalive`
|
|
19
|
+
// requests draw on a 64 KiB in-flight browser quota shared across the page, so
|
|
20
|
+
// a burst does not just slow things down — some of it gets rejected, and the
|
|
21
|
+
// original `.catch(() => {})` made that indistinguishable from silence.
|
|
22
|
+
//
|
|
23
|
+
// So entries are buffered and flushed as a batch:
|
|
24
|
+
//
|
|
25
|
+
// - every FLUSH_INTERVAL_MS, timer armed when an entry lands in an empty
|
|
26
|
+
// buffer (the common case: a few lines coalesce into one request),
|
|
27
|
+
// - immediately once buffered output reaches MAX_BATCH_BYTES,
|
|
28
|
+
// - on a microtask for anything error-level, so a crash never waits 250ms
|
|
29
|
+
// while a same-tick burst still coalesces,
|
|
30
|
+
// - synchronously via sendBeacon on pagehide / visibilitychange→hidden,
|
|
31
|
+
// which is the only way output survives a navigation or a crash-reload.
|
|
32
|
+
//
|
|
33
|
+
// At most ONE request is in flight. Entries logged during it keep buffering and
|
|
34
|
+
// go out when it settles, which is what makes the printed order the log order —
|
|
35
|
+
// six parallel POSTs would interleave on the server.
|
|
36
|
+
//
|
|
37
|
+
// Nothing is ever dropped silently. A full buffer, a rejected request and an
|
|
38
|
+
// over-quota request all increment a counter, and the next batch to leave
|
|
39
|
+
// carries a synthetic warn line saying how many lines were lost and why. The
|
|
40
|
+
// failure this replaces was a dead sink that looked exactly like working code.
|
|
41
|
+
//
|
|
42
|
+
// ── Legacy endpoints ─────────────────────────────────────────────────────
|
|
43
|
+
//
|
|
44
|
+
// The receiving endpoint is a scaffold-once file the PROJECT owns (a Vite
|
|
45
|
+
// plugin / a Next.js route handler), so upgrading this package through npm will
|
|
46
|
+
// routinely meet an endpoint that predates batching. Such an endpoint answers
|
|
47
|
+
// 204 and prints one empty line for the whole batch — total, silent loss. A v2
|
|
48
|
+
// endpoint therefore returns `X-Forge-Devlog: 2`, and a 2xx without it makes
|
|
49
|
+
// this client fall back permanently to one v1 POST per line (plus one warn
|
|
50
|
+
// line telling the developer to update the endpoint).
|
|
51
|
+
//
|
|
11
52
|
// DEV ONLY, and structurally so. The receiving endpoint is a Vite plugin
|
|
12
53
|
// gated `apply: "serve"` / a Next.js route behind a NODE_ENV check, so it does
|
|
13
54
|
// not exist in a production build; installDevLogging() additionally no-ops
|
|
@@ -20,61 +61,299 @@
|
|
|
20
61
|
/** Console methods this module mirrors. */
|
|
21
62
|
const LEVELS = ["log", "info", "warn", "error", "debug"];
|
|
22
63
|
export const DEV_LOG_ENDPOINT = "/__forge/log";
|
|
64
|
+
/** Response header a batching endpoint sets. Its ABSENCE selects v1 fallback. */
|
|
65
|
+
const PROTOCOL_HEADER = "X-Forge-Devlog";
|
|
66
|
+
/** Cap a single line so a runaway loop cannot fill the disk. Matches the endpoints. */
|
|
67
|
+
const MAX_LINE = 8_000;
|
|
68
|
+
/** How long a line may wait for company before it is sent. */
|
|
69
|
+
const FLUSH_INTERVAL_MS = 250;
|
|
70
|
+
/**
|
|
71
|
+
* Per-request ceiling. Deliberately half of the browser's 64 KiB shared
|
|
72
|
+
* `keepalive` in-flight quota: with one request in flight, a batch cannot be
|
|
73
|
+
* the thing that pushes the page over it.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_BATCH_BYTES = 32 * 1024;
|
|
76
|
+
/** Buffer ceiling. Past it entries are dropped and counted, never queued forever. */
|
|
77
|
+
const MAX_BUFFER_ENTRIES = 5_000;
|
|
23
78
|
const original = {};
|
|
24
79
|
let installed = false;
|
|
25
80
|
// Guards against infinite recursion. The forwarder calls fetch(), and anything
|
|
26
81
|
// that logs inside fetch (an interceptor, a polyfill, a devtools extension)
|
|
27
|
-
// would otherwise re-enter the override forever.
|
|
82
|
+
// would otherwise re-enter the override forever. Such a line is still mirrored
|
|
83
|
+
// to the real console — it is only forwarding that is suppressed.
|
|
28
84
|
let forwarding = false;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
85
|
+
let endpointUrl = DEV_LOG_ENDPOINT;
|
|
86
|
+
let buffer = [];
|
|
87
|
+
let bufferedBytes = 0;
|
|
88
|
+
let flushTimer;
|
|
89
|
+
let requestInFlight = false;
|
|
90
|
+
let errorFlushQueued = false;
|
|
91
|
+
let droppedCount = 0;
|
|
92
|
+
let droppedReason = "";
|
|
93
|
+
/** Set for good once an endpoint is known not to understand batches. */
|
|
94
|
+
let legacyEndpoint = false;
|
|
95
|
+
/**
|
|
96
|
+
* Render one console argument as a string.
|
|
97
|
+
*
|
|
98
|
+
* Circular structures and BigInt are the two cases a plain JSON.stringify
|
|
99
|
+
* throws on, and the old fallback to String(value) turned both into
|
|
100
|
+
* "[object Object]" — deleting exactly the shape you were trying to read. The
|
|
101
|
+
* replacer keeps the object and names the cycle instead.
|
|
102
|
+
*/
|
|
103
|
+
function render(value) {
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return value;
|
|
106
|
+
if (value instanceof Error) {
|
|
107
|
+
return `${value.name}: ${value.message}${value.stack ? `\n${value.stack}` : ""}`;
|
|
33
108
|
}
|
|
34
|
-
if (typeof arg === "string")
|
|
35
|
-
return arg;
|
|
36
109
|
try {
|
|
37
|
-
|
|
110
|
+
const seen = new WeakSet();
|
|
111
|
+
return (JSON.stringify(value, (_key, val) => {
|
|
112
|
+
if (typeof val === "bigint")
|
|
113
|
+
return `${val}n`;
|
|
114
|
+
if (typeof val === "object" && val !== null) {
|
|
115
|
+
if (seen.has(val))
|
|
116
|
+
return "[Circular]";
|
|
117
|
+
seen.add(val);
|
|
118
|
+
}
|
|
119
|
+
return val;
|
|
120
|
+
}) ?? String(value));
|
|
38
121
|
}
|
|
39
122
|
catch {
|
|
40
|
-
//
|
|
41
|
-
return String(
|
|
123
|
+
// DOM nodes, getters that throw — never let logging throw.
|
|
124
|
+
return String(value);
|
|
42
125
|
}
|
|
43
126
|
}
|
|
44
|
-
function
|
|
127
|
+
function truncate(msg) {
|
|
128
|
+
return msg.length > MAX_LINE ? `${msg.slice(0, MAX_LINE)}… (truncated)` : msg;
|
|
129
|
+
}
|
|
130
|
+
function entryBytes(entry) {
|
|
131
|
+
return JSON.stringify(entry).length;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Buffer one line and decide whether it can wait.
|
|
135
|
+
*
|
|
136
|
+
* Arguments are rendered HERE rather than at flush time: a console argument is
|
|
137
|
+
* a live object, and by the time a batch leaves 250ms later the array it logged
|
|
138
|
+
* may have been mutated or emptied. The snapshot is the value at the call.
|
|
139
|
+
*/
|
|
140
|
+
function capture(level, args) {
|
|
45
141
|
if (forwarding)
|
|
46
142
|
return;
|
|
143
|
+
if (buffer.length >= MAX_BUFFER_ENTRIES) {
|
|
144
|
+
droppedCount += 1;
|
|
145
|
+
droppedReason = "buffer full";
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const entry = { level, msg: truncate(args.map(render).join(" ")) };
|
|
149
|
+
const wasEmpty = buffer.length === 0;
|
|
150
|
+
buffer.push(entry);
|
|
151
|
+
bufferedBytes += entryBytes(entry);
|
|
152
|
+
if (bufferedBytes >= MAX_BATCH_BYTES) {
|
|
153
|
+
flush();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (level === "error") {
|
|
157
|
+
// An error is what someone is waiting for; it must not sit in a timer. The
|
|
158
|
+
// microtask (rather than a direct flush) lets a same-tick burst — a failing
|
|
159
|
+
// render logging three times — still leave as one request.
|
|
160
|
+
if (!errorFlushQueued) {
|
|
161
|
+
errorFlushQueued = true;
|
|
162
|
+
queueMicrotask(() => {
|
|
163
|
+
errorFlushQueued = false;
|
|
164
|
+
flush();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (wasEmpty)
|
|
170
|
+
armTimer();
|
|
171
|
+
}
|
|
172
|
+
function armTimer() {
|
|
173
|
+
if (flushTimer !== undefined)
|
|
174
|
+
return;
|
|
175
|
+
flushTimer = setTimeout(() => {
|
|
176
|
+
flushTimer = undefined;
|
|
177
|
+
flush();
|
|
178
|
+
}, FLUSH_INTERVAL_MS);
|
|
179
|
+
}
|
|
180
|
+
function clearFlushTimer() {
|
|
181
|
+
if (flushTimer === undefined)
|
|
182
|
+
return;
|
|
183
|
+
clearTimeout(flushTimer);
|
|
184
|
+
flushTimer = undefined;
|
|
185
|
+
}
|
|
186
|
+
/** Pull entries off the front of the buffer without exceeding MAX_BATCH_BYTES. */
|
|
187
|
+
function takeBatch(limit = MAX_BATCH_BYTES) {
|
|
188
|
+
const batch = [];
|
|
189
|
+
let size = '{"entries":[]}'.length;
|
|
190
|
+
while (buffer.length > 0) {
|
|
191
|
+
const entry = buffer[0];
|
|
192
|
+
const cost = entryBytes(entry) + (batch.length > 0 ? 1 : 0);
|
|
193
|
+
// Always take at least one entry: a single line that somehow exceeds the
|
|
194
|
+
// limit must still leave, or it stalls every line behind it forever.
|
|
195
|
+
if (batch.length > 0 && size + cost > limit)
|
|
196
|
+
break;
|
|
197
|
+
buffer.shift();
|
|
198
|
+
bufferedBytes -= entryBytes(entry);
|
|
199
|
+
batch.push(entry);
|
|
200
|
+
size += cost;
|
|
201
|
+
}
|
|
202
|
+
if (buffer.length === 0)
|
|
203
|
+
bufferedBytes = 0;
|
|
204
|
+
return batch;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Prepend the "we lost lines" notice, if any, so it travels with the next batch
|
|
208
|
+
* rather than needing a request of its own.
|
|
209
|
+
*/
|
|
210
|
+
function drainDroppedNotice() {
|
|
211
|
+
if (droppedCount === 0)
|
|
212
|
+
return;
|
|
213
|
+
const notice = {
|
|
214
|
+
level: "warn",
|
|
215
|
+
msg: `[forge-devlog] dropped ${droppedCount} lines (${droppedReason})`,
|
|
216
|
+
};
|
|
217
|
+
buffer.unshift(notice);
|
|
218
|
+
bufferedBytes += entryBytes(notice);
|
|
219
|
+
droppedCount = 0;
|
|
220
|
+
droppedReason = "";
|
|
221
|
+
}
|
|
222
|
+
function post(body) {
|
|
223
|
+
// The recursion guard must wrap the CALL, not the promise: a fetch polyfill
|
|
224
|
+
// that logs does so synchronously from inside fetch().
|
|
47
225
|
forwarding = true;
|
|
48
226
|
try {
|
|
49
|
-
|
|
50
|
-
void fetch(endpoint, {
|
|
227
|
+
return fetch(endpointUrl, {
|
|
51
228
|
method: "POST",
|
|
52
229
|
headers: { "Content-Type": "application/json" },
|
|
53
|
-
body
|
|
230
|
+
body,
|
|
54
231
|
// A log emitted during unload (the interesting case for a crash on
|
|
55
232
|
// navigation) is cancelled without this.
|
|
56
233
|
keepalive: true,
|
|
57
234
|
// Never let log forwarding trip a credentials or CORS policy.
|
|
58
235
|
credentials: "omit",
|
|
59
|
-
}).catch(() => {
|
|
60
|
-
// The dev server going away must not surface as an app error.
|
|
61
236
|
});
|
|
62
237
|
}
|
|
63
238
|
finally {
|
|
64
239
|
forwarding = false;
|
|
65
240
|
}
|
|
66
241
|
}
|
|
67
|
-
|
|
68
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Send one request's worth of buffered output, if nothing is in flight.
|
|
244
|
+
*
|
|
245
|
+
* The single-flight rule is what guarantees the dev server prints lines in the
|
|
246
|
+
* order they were logged, and it doubles as backpressure: a slow endpoint makes
|
|
247
|
+
* batches bigger rather than making requests pile up.
|
|
248
|
+
*/
|
|
249
|
+
function flush() {
|
|
250
|
+
if (requestInFlight)
|
|
251
|
+
return;
|
|
252
|
+
drainDroppedNotice();
|
|
253
|
+
if (buffer.length === 0)
|
|
254
|
+
return;
|
|
255
|
+
clearFlushTimer();
|
|
256
|
+
const batch = legacyEndpoint ? takeBatch(0) : takeBatch();
|
|
257
|
+
if (batch.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
const body = legacyEndpoint
|
|
260
|
+
? JSON.stringify(batch[0])
|
|
261
|
+
: JSON.stringify({ entries: batch });
|
|
262
|
+
requestInFlight = true;
|
|
263
|
+
post(body).then((response) => {
|
|
264
|
+
requestInFlight = false;
|
|
265
|
+
if (!response.ok) {
|
|
266
|
+
countDropped(batch.length, `endpoint returned ${response.status}`);
|
|
267
|
+
}
|
|
268
|
+
else if (!legacyEndpoint && !response.headers.get(PROTOCOL_HEADER)) {
|
|
269
|
+
adoptLegacyMode(batch);
|
|
270
|
+
}
|
|
271
|
+
if (buffer.length > 0 || droppedCount > 0)
|
|
272
|
+
flush();
|
|
273
|
+
}, () => {
|
|
274
|
+
requestInFlight = false;
|
|
275
|
+
countDropped(batch.length, "request failed");
|
|
276
|
+
if (buffer.length > 0)
|
|
277
|
+
flush();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
function countDropped(count, reason) {
|
|
281
|
+
droppedCount += count;
|
|
282
|
+
droppedReason = reason;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* A 2xx with no protocol header came from an endpoint written before batching.
|
|
286
|
+
* It parsed `{entries:[...]}` as a v1 body, found no `msg`, and printed one
|
|
287
|
+
* empty line — so this batch is lost unless it is re-sent one line at a time,
|
|
288
|
+
* and so is everything after it.
|
|
289
|
+
*/
|
|
290
|
+
function adoptLegacyMode(lost) {
|
|
291
|
+
legacyEndpoint = true;
|
|
292
|
+
const notice = {
|
|
293
|
+
level: "warn",
|
|
294
|
+
msg: "[forge-devlog] this dev server's /__forge/log endpoint predates batched " +
|
|
295
|
+
"posts; update it (forge skill: debug) — falling back to one request per line",
|
|
296
|
+
};
|
|
297
|
+
buffer.unshift(notice, ...lost);
|
|
298
|
+
bufferedBytes += [notice, ...lost].reduce((n, e) => n + entryBytes(e), 0);
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Last-chance flush on page teardown.
|
|
302
|
+
*
|
|
303
|
+
* fetch — even with keepalive — is not reliable here, and an async flush is
|
|
304
|
+
* hopeless: the event loop may not run again. sendBeacon hands the bytes to the
|
|
305
|
+
* browser to deliver after the document is gone, which is precisely the case
|
|
306
|
+
* where the last few log lines explain what happened.
|
|
307
|
+
*/
|
|
308
|
+
function flushOnUnload() {
|
|
309
|
+
drainDroppedNotice();
|
|
310
|
+
if (buffer.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
const canBeacon = typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function";
|
|
313
|
+
if (!canBeacon) {
|
|
314
|
+
// No beacon: keepalive fetch is the only remaining option, and one request
|
|
315
|
+
// has a better chance of leaving than a queue of them.
|
|
316
|
+
flush();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
forwarding = true;
|
|
320
|
+
try {
|
|
321
|
+
while (buffer.length > 0) {
|
|
322
|
+
const batch = legacyEndpoint ? takeBatch(0) : takeBatch();
|
|
323
|
+
const body = legacyEndpoint
|
|
324
|
+
? JSON.stringify(batch[0])
|
|
325
|
+
: JSON.stringify({ entries: batch });
|
|
326
|
+
// text/plain is not a decoration: sendBeacon with application/json is a
|
|
327
|
+
// non-simple request and would need a CORS preflight it cannot make. The
|
|
328
|
+
// endpoints parse the body regardless of Content-Type for this reason.
|
|
329
|
+
const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
|
|
330
|
+
if (!navigator.sendBeacon(endpointUrl, blob)) {
|
|
331
|
+
// The beacon queue is full (64 KiB per origin). Nothing later will fit
|
|
332
|
+
// either, so stop and account for what is left.
|
|
333
|
+
countDropped(batch.length + buffer.length, "beacon queue full");
|
|
334
|
+
buffer = [];
|
|
335
|
+
bufferedBytes = 0;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
forwarding = false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function onWindowError(event) {
|
|
345
|
+
capture("error", [
|
|
69
346
|
event.error ??
|
|
70
347
|
`${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,
|
|
71
348
|
]);
|
|
72
349
|
}
|
|
73
|
-
function onRejection(
|
|
74
|
-
|
|
350
|
+
function onRejection(event) {
|
|
351
|
+
capture("error", ["unhandled rejection:", event.reason]);
|
|
75
352
|
}
|
|
76
353
|
let errorHandler;
|
|
77
354
|
let rejectionHandler;
|
|
355
|
+
let pageHideHandler;
|
|
356
|
+
let visibilityHandler;
|
|
78
357
|
/**
|
|
79
358
|
* Mirror console output and uncaught errors to the dev server, which prints
|
|
80
359
|
* them into .forge/logs/<env>/frontend_<name>.log.
|
|
@@ -100,6 +379,13 @@ export function installDevLogging(options) {
|
|
|
100
379
|
return; // SSR / prerender pass
|
|
101
380
|
const { endpoint = DEV_LOG_ENDPOINT, mirrorToConsole = true } = options;
|
|
102
381
|
installed = true;
|
|
382
|
+
endpointUrl = endpoint;
|
|
383
|
+
buffer = [];
|
|
384
|
+
bufferedBytes = 0;
|
|
385
|
+
droppedCount = 0;
|
|
386
|
+
droppedReason = "";
|
|
387
|
+
legacyEndpoint = false;
|
|
388
|
+
requestInFlight = false;
|
|
103
389
|
for (const level of LEVELS) {
|
|
104
390
|
// Keep the ORIGINAL reference, not a bound copy: uninstall must restore
|
|
105
391
|
// console.log to the identical function it replaced, so a second consumer
|
|
@@ -109,18 +395,33 @@ export function installDevLogging(options) {
|
|
|
109
395
|
console[level] = (...args) => {
|
|
110
396
|
if (mirrorToConsole)
|
|
111
397
|
previous.apply(console, args);
|
|
112
|
-
|
|
398
|
+
capture(level, args);
|
|
113
399
|
};
|
|
114
400
|
}
|
|
115
|
-
errorHandler = (e) => onWindowError(
|
|
116
|
-
rejectionHandler = (e) => onRejection(
|
|
401
|
+
errorHandler = (e) => onWindowError(e);
|
|
402
|
+
rejectionHandler = (e) => onRejection(e);
|
|
403
|
+
pageHideHandler = () => flushOnUnload();
|
|
117
404
|
window.addEventListener("error", errorHandler);
|
|
118
405
|
window.addEventListener("unhandledrejection", rejectionHandler);
|
|
406
|
+
window.addEventListener("pagehide", pageHideHandler);
|
|
407
|
+
if (typeof document !== "undefined") {
|
|
408
|
+
// visibilitychange→hidden is the only teardown signal mobile Safari
|
|
409
|
+
// reliably fires; pagehide alone loses the whole buffer there.
|
|
410
|
+
visibilityHandler = () => {
|
|
411
|
+
if (document.visibilityState === "hidden")
|
|
412
|
+
flushOnUnload();
|
|
413
|
+
};
|
|
414
|
+
document.addEventListener("visibilitychange", visibilityHandler);
|
|
415
|
+
}
|
|
119
416
|
}
|
|
120
417
|
/** Restore the original console methods and remove the window listeners. */
|
|
121
418
|
export function uninstallDevLogging() {
|
|
122
419
|
if (!installed)
|
|
123
420
|
return;
|
|
421
|
+
// Flush before restoring: whatever is buffered was logged while this was
|
|
422
|
+
// installed, and a teardown is no reason to lose it.
|
|
423
|
+
clearFlushTimer();
|
|
424
|
+
flush();
|
|
124
425
|
for (const level of LEVELS) {
|
|
125
426
|
const previous = original[level];
|
|
126
427
|
if (previous)
|
|
@@ -131,8 +432,14 @@ export function uninstallDevLogging() {
|
|
|
131
432
|
window.removeEventListener("error", errorHandler);
|
|
132
433
|
if (rejectionHandler)
|
|
133
434
|
window.removeEventListener("unhandledrejection", rejectionHandler);
|
|
435
|
+
if (pageHideHandler)
|
|
436
|
+
window.removeEventListener("pagehide", pageHideHandler);
|
|
437
|
+
if (visibilityHandler && typeof document !== "undefined")
|
|
438
|
+
document.removeEventListener("visibilitychange", visibilityHandler);
|
|
134
439
|
errorHandler = undefined;
|
|
135
440
|
rejectionHandler = undefined;
|
|
441
|
+
pageHideHandler = undefined;
|
|
442
|
+
visibilityHandler = undefined;
|
|
136
443
|
installed = false;
|
|
137
444
|
}
|
|
138
445
|
/** Whether the console override is currently installed. Exposed for tests. */
|
package/dist/devlog.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"devlog.js","sourceRoot":"","sources":["../src/devlog.ts"],"names":[],"mappings":"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","sourcesContent":["// Dev-only browser log forwarding.\n//\n// A browser's console is a dead end: an agent debugging a scaffolded frontend\n// cannot open devtools, so every console line and — worse — every uncaught\n// error is invisible to it. The backend has no such gap (structured slog, the\n// observe ComponentChain, and forge's dev loop already tees each process's\n// stdout to .forge/logs/<env>/). This closes the frontend half by POSTing\n// browser console output to a dev-server endpoint that prints it, so it lands\n// in .forge/logs/<env>/frontend_<name>.log next to the server logs.\n//\n// DEV ONLY, and structurally so. The receiving endpoint is a Vite plugin\n// gated `apply: \"serve\"` / a Next.js route behind a NODE_ENV check, so it does\n// not exist in a production build; installDevLogging() additionally no-ops\n// unless it is handed dev: true. A production bundle therefore has no\n// endpoint to post to AND no code that would post.\n//\n// It is also reversible: uninstallDevLogging() restores the original console\n// methods, and the scaffolded call site is an ordinary line in main.tsx /\n// providers.tsx that a project can delete outright.\n\n/** Console methods this module mirrors. */\nconst LEVELS = [\"log\", \"info\", \"warn\", \"error\", \"debug\"] as const;\n\ntype Level = (typeof LEVELS)[number];\n\n/** The wire shape the dev-server endpoint expects. */\nexport interface DevLogPayload {\n level: Level | \"error\";\n msg: string;\n}\n\nexport interface DevLoggingOptions {\n /**\n * Whether to install at all. Pass `import.meta.env.DEV` (Vite) or\n * `process.env.NODE_ENV !== \"production\"` (Next.js). When false this is a\n * no-op, so the call site needs no conditional of its own.\n */\n dev: boolean;\n /** Endpoint the dev server serves. Defaults to the forge convention. */\n endpoint?: string;\n /**\n * Also keep writing to the real console. Default true — the browser devtools\n * experience should be unchanged; this feature ADDS a sink, it does not move\n * one.\n */\n mirrorToConsole?: boolean;\n}\n\nexport const DEV_LOG_ENDPOINT = \"/__forge/log\";\n\nconst original: Partial<Record<Level, (...args: unknown[]) => void>> = {};\nlet installed = false;\n\n// Guards against infinite recursion. The forwarder calls fetch(), and anything\n// that logs inside fetch (an interceptor, a polyfill, a devtools extension)\n// would otherwise re-enter the override forever.\nlet forwarding = false;\n\n/** Render one console argument as a string, keeping Error stacks intact. */\nfunction render(arg: unknown): string {\n if (arg instanceof Error) {\n return `${arg.name}: ${arg.message}${arg.stack ? `\\n${arg.stack}` : \"\"}`;\n }\n if (typeof arg === \"string\") return arg;\n try {\n return JSON.stringify(arg);\n } catch {\n // Circular structures, BigInt, DOM nodes — never let logging throw.\n return String(arg);\n }\n}\n\nfunction forward(\n endpoint: string,\n level: DevLogPayload[\"level\"],\n args: unknown[],\n): void {\n if (forwarding) return;\n forwarding = true;\n try {\n const body: DevLogPayload = { level, msg: args.map(render).join(\" \") };\n void fetch(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n // A log emitted during unload (the interesting case for a crash on\n // navigation) is cancelled without this.\n keepalive: true,\n // Never let log forwarding trip a credentials or CORS policy.\n credentials: \"omit\",\n }).catch(() => {\n // The dev server going away must not surface as an app error.\n });\n } finally {\n forwarding = false;\n }\n}\n\nfunction onWindowError(endpoint: string, event: ErrorEvent): void {\n forward(endpoint, \"error\", [\n event.error ??\n `${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,\n ]);\n}\n\nfunction onRejection(endpoint: string, event: PromiseRejectionEvent): void {\n forward(endpoint, \"error\", [\"unhandled rejection:\", event.reason]);\n}\n\nlet errorHandler: ((e: ErrorEvent) => void) | undefined;\nlet rejectionHandler: ((e: PromiseRejectionEvent) => void) | undefined;\n\n/**\n * Mirror console output and uncaught errors to the dev server, which prints\n * them into .forge/logs/<env>/frontend_<name>.log.\n *\n * The window listeners are the point of the feature as much as the console\n * override is: an uncaught TypeError or a rejected promise produces no console\n * call of its own, and those are exactly the failures worth seeing.\n *\n * Safe to call more than once; the second call is a no-op.\n */\nexport function installDevLogging(options: DevLoggingOptions): void {\n // `dev` is read off `options` rather than destructured with a default, and\n // checked FIRST. A bundler constant-folds `import.meta.env.DEV` to false at\n // the CALL SITE and can then drop the argument entirely, leaving a bare\n // `installDevLogging({})` — with a defaulted `dev` that call would install\n // the override in a production bundle. `options?.dev !== true` is therefore\n // fail-closed: absent, undefined, or anything non-true means do nothing.\n if (options?.dev !== true) return;\n if (installed) return;\n if (typeof window === \"undefined\") return; // SSR / prerender pass\n\n const { endpoint = DEV_LOG_ENDPOINT, mirrorToConsole = true } = options;\n installed = true;\n\n for (const level of LEVELS) {\n // Keep the ORIGINAL reference, not a bound copy: uninstall must restore\n // console.log to the identical function it replaced, so a second consumer\n // holding that reference (or a test asserting on it) sees no difference.\n const previous = console[level] as (...args: unknown[]) => void;\n original[level] = previous;\n console[level] = (...args: unknown[]) => {\n if (mirrorToConsole) previous.apply(console, args);\n forward(endpoint, level, args);\n };\n }\n\n errorHandler = (e) => onWindowError(endpoint, e);\n rejectionHandler = (e) => onRejection(endpoint, e);\n window.addEventListener(\"error\", errorHandler);\n window.addEventListener(\"unhandledrejection\", rejectionHandler);\n}\n\n/** Restore the original console methods and remove the window listeners. */\nexport function uninstallDevLogging(): void {\n if (!installed) return;\n for (const level of LEVELS) {\n const previous = original[level];\n if (previous) console[level] = previous;\n delete original[level];\n }\n if (errorHandler) window.removeEventListener(\"error\", errorHandler);\n if (rejectionHandler)\n window.removeEventListener(\"unhandledrejection\", rejectionHandler);\n errorHandler = undefined;\n rejectionHandler = undefined;\n installed = false;\n}\n\n/** Whether the console override is currently installed. Exposed for tests. */\nexport function devLoggingInstalled(): boolean {\n return installed;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"devlog.js","sourceRoot":"","sources":["../src/devlog.ts"],"names":[],"mappings":"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","sourcesContent":["// Dev-only browser log forwarding.\n//\n// A browser's console is a dead end: an agent debugging a scaffolded frontend\n// cannot open devtools, so every console line and — worse — every uncaught\n// error is invisible to it. The backend has no such gap (structured slog, the\n// observe ComponentChain, and forge's dev loop already tees each process's\n// stdout to .forge/logs/<env>/). This closes the frontend half by POSTing\n// browser console output to a dev-server endpoint that prints it, so it lands\n// in .forge/logs/<env>/frontend_<name>.log next to the server logs.\n//\n// ── Why this batches (protocol v2) ───────────────────────────────────────\n//\n// The first version sent one fetch POST per console call. Measured in a real\n// app that logs freely, that is ~80 requests/s at light load and 200+/s in a\n// burst, and those requests are not free bystanders: they share the browser's\n// six-connections-per-origin pool and the dev server's single event loop with\n// the app's own RPCs. In the same session, RPC latency averaged 37ms in quiet\n// seconds and 127ms in seconds carrying 200+ log lines. Worse, `keepalive`\n// requests draw on a 64 KiB in-flight browser quota shared across the page, so\n// a burst does not just slow things down — some of it gets rejected, and the\n// original `.catch(() => {})` made that indistinguishable from silence.\n//\n// So entries are buffered and flushed as a batch:\n//\n// - every FLUSH_INTERVAL_MS, timer armed when an entry lands in an empty\n// buffer (the common case: a few lines coalesce into one request),\n// - immediately once buffered output reaches MAX_BATCH_BYTES,\n// - on a microtask for anything error-level, so a crash never waits 250ms\n// while a same-tick burst still coalesces,\n// - synchronously via sendBeacon on pagehide / visibilitychange→hidden,\n// which is the only way output survives a navigation or a crash-reload.\n//\n// At most ONE request is in flight. Entries logged during it keep buffering and\n// go out when it settles, which is what makes the printed order the log order —\n// six parallel POSTs would interleave on the server.\n//\n// Nothing is ever dropped silently. A full buffer, a rejected request and an\n// over-quota request all increment a counter, and the next batch to leave\n// carries a synthetic warn line saying how many lines were lost and why. The\n// failure this replaces was a dead sink that looked exactly like working code.\n//\n// ── Legacy endpoints ─────────────────────────────────────────────────────\n//\n// The receiving endpoint is a scaffold-once file the PROJECT owns (a Vite\n// plugin / a Next.js route handler), so upgrading this package through npm will\n// routinely meet an endpoint that predates batching. Such an endpoint answers\n// 204 and prints one empty line for the whole batch — total, silent loss. A v2\n// endpoint therefore returns `X-Forge-Devlog: 2`, and a 2xx without it makes\n// this client fall back permanently to one v1 POST per line (plus one warn\n// line telling the developer to update the endpoint).\n//\n// DEV ONLY, and structurally so. The receiving endpoint is a Vite plugin\n// gated `apply: \"serve\"` / a Next.js route behind a NODE_ENV check, so it does\n// not exist in a production build; installDevLogging() additionally no-ops\n// unless it is handed dev: true. A production bundle therefore has no\n// endpoint to post to AND no code that would post.\n//\n// It is also reversible: uninstallDevLogging() restores the original console\n// methods, and the scaffolded call site is an ordinary line in main.tsx /\n// providers.tsx that a project can delete outright.\n\n/** Console methods this module mirrors. */\nconst LEVELS = [\"log\", \"info\", \"warn\", \"error\", \"debug\"] as const;\n\ntype Level = (typeof LEVELS)[number];\n\n/**\n * One log line on the wire.\n *\n * Also the whole v1 body: a legacy endpoint receives exactly this object, which\n * is why the shape is unchanged and still exported.\n */\nexport interface DevLogPayload {\n level: Level | \"error\";\n msg: string;\n}\n\n/** The v2 body: many lines, one request. */\nexport interface DevLogBatch {\n entries: DevLogPayload[];\n}\n\nexport interface DevLoggingOptions {\n /**\n * Whether to install at all. Pass `import.meta.env.DEV` (Vite) or\n * `process.env.NODE_ENV !== \"production\"` (Next.js). When false this is a\n * no-op, so the call site needs no conditional of its own.\n */\n dev: boolean;\n /** Endpoint the dev server serves. Defaults to the forge convention. */\n endpoint?: string;\n /**\n * Also keep writing to the real console. Default true — the browser devtools\n * experience should be unchanged; this feature ADDS a sink, it does not move\n * one.\n */\n mirrorToConsole?: boolean;\n}\n\nexport const DEV_LOG_ENDPOINT = \"/__forge/log\";\n\n/** Response header a batching endpoint sets. Its ABSENCE selects v1 fallback. */\nconst PROTOCOL_HEADER = \"X-Forge-Devlog\";\n\n/** Cap a single line so a runaway loop cannot fill the disk. Matches the endpoints. */\nconst MAX_LINE = 8_000;\n\n/** How long a line may wait for company before it is sent. */\nconst FLUSH_INTERVAL_MS = 250;\n\n/**\n * Per-request ceiling. Deliberately half of the browser's 64 KiB shared\n * `keepalive` in-flight quota: with one request in flight, a batch cannot be\n * the thing that pushes the page over it.\n */\nconst MAX_BATCH_BYTES = 32 * 1024;\n\n/** Buffer ceiling. Past it entries are dropped and counted, never queued forever. */\nconst MAX_BUFFER_ENTRIES = 5_000;\n\nconst original: Partial<Record<Level, (...args: unknown[]) => void>> = {};\nlet installed = false;\n\n// Guards against infinite recursion. The forwarder calls fetch(), and anything\n// that logs inside fetch (an interceptor, a polyfill, a devtools extension)\n// would otherwise re-enter the override forever. Such a line is still mirrored\n// to the real console — it is only forwarding that is suppressed.\nlet forwarding = false;\n\nlet endpointUrl = DEV_LOG_ENDPOINT;\nlet buffer: DevLogPayload[] = [];\nlet bufferedBytes = 0;\nlet flushTimer: ReturnType<typeof setTimeout> | undefined;\nlet requestInFlight = false;\nlet errorFlushQueued = false;\nlet droppedCount = 0;\nlet droppedReason = \"\";\n/** Set for good once an endpoint is known not to understand batches. */\nlet legacyEndpoint = false;\n\n/**\n * Render one console argument as a string.\n *\n * Circular structures and BigInt are the two cases a plain JSON.stringify\n * throws on, and the old fallback to String(value) turned both into\n * \"[object Object]\" — deleting exactly the shape you were trying to read. The\n * replacer keeps the object and names the cycle instead.\n */\nfunction render(value: unknown): string {\n if (typeof value === \"string\") return value;\n if (value instanceof Error) {\n return `${value.name}: ${value.message}${value.stack ? `\\n${value.stack}` : \"\"}`;\n }\n try {\n const seen = new WeakSet<object>();\n return (\n JSON.stringify(value, (_key, val: unknown) => {\n if (typeof val === \"bigint\") return `${val}n`;\n if (typeof val === \"object\" && val !== null) {\n if (seen.has(val)) return \"[Circular]\";\n seen.add(val);\n }\n return val;\n }) ?? String(value)\n );\n } catch {\n // DOM nodes, getters that throw — never let logging throw.\n return String(value);\n }\n}\n\nfunction truncate(msg: string): string {\n return msg.length > MAX_LINE ? `${msg.slice(0, MAX_LINE)}… (truncated)` : msg;\n}\n\nfunction entryBytes(entry: DevLogPayload): number {\n return JSON.stringify(entry).length;\n}\n\n/**\n * Buffer one line and decide whether it can wait.\n *\n * Arguments are rendered HERE rather than at flush time: a console argument is\n * a live object, and by the time a batch leaves 250ms later the array it logged\n * may have been mutated or emptied. The snapshot is the value at the call.\n */\nfunction capture(level: DevLogPayload[\"level\"], args: unknown[]): void {\n if (forwarding) return;\n\n if (buffer.length >= MAX_BUFFER_ENTRIES) {\n droppedCount += 1;\n droppedReason = \"buffer full\";\n return;\n }\n\n const entry: DevLogPayload = { level, msg: truncate(args.map(render).join(\" \")) };\n const wasEmpty = buffer.length === 0;\n buffer.push(entry);\n bufferedBytes += entryBytes(entry);\n\n if (bufferedBytes >= MAX_BATCH_BYTES) {\n flush();\n return;\n }\n\n if (level === \"error\") {\n // An error is what someone is waiting for; it must not sit in a timer. The\n // microtask (rather than a direct flush) lets a same-tick burst — a failing\n // render logging three times — still leave as one request.\n if (!errorFlushQueued) {\n errorFlushQueued = true;\n queueMicrotask(() => {\n errorFlushQueued = false;\n flush();\n });\n }\n return;\n }\n\n if (wasEmpty) armTimer();\n}\n\nfunction armTimer(): void {\n if (flushTimer !== undefined) return;\n flushTimer = setTimeout(() => {\n flushTimer = undefined;\n flush();\n }, FLUSH_INTERVAL_MS);\n}\n\nfunction clearFlushTimer(): void {\n if (flushTimer === undefined) return;\n clearTimeout(flushTimer);\n flushTimer = undefined;\n}\n\n/** Pull entries off the front of the buffer without exceeding MAX_BATCH_BYTES. */\nfunction takeBatch(limit = MAX_BATCH_BYTES): DevLogPayload[] {\n const batch: DevLogPayload[] = [];\n let size = '{\"entries\":[]}'.length;\n while (buffer.length > 0) {\n const entry = buffer[0] as DevLogPayload;\n const cost = entryBytes(entry) + (batch.length > 0 ? 1 : 0);\n // Always take at least one entry: a single line that somehow exceeds the\n // limit must still leave, or it stalls every line behind it forever.\n if (batch.length > 0 && size + cost > limit) break;\n buffer.shift();\n bufferedBytes -= entryBytes(entry);\n batch.push(entry);\n size += cost;\n }\n if (buffer.length === 0) bufferedBytes = 0;\n return batch;\n}\n\n/**\n * Prepend the \"we lost lines\" notice, if any, so it travels with the next batch\n * rather than needing a request of its own.\n */\nfunction drainDroppedNotice(): void {\n if (droppedCount === 0) return;\n const notice: DevLogPayload = {\n level: \"warn\",\n msg: `[forge-devlog] dropped ${droppedCount} lines (${droppedReason})`,\n };\n buffer.unshift(notice);\n bufferedBytes += entryBytes(notice);\n droppedCount = 0;\n droppedReason = \"\";\n}\n\nfunction post(body: string): Promise<Response> {\n // The recursion guard must wrap the CALL, not the promise: a fetch polyfill\n // that logs does so synchronously from inside fetch().\n forwarding = true;\n try {\n return fetch(endpointUrl, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body,\n // A log emitted during unload (the interesting case for a crash on\n // navigation) is cancelled without this.\n keepalive: true,\n // Never let log forwarding trip a credentials or CORS policy.\n credentials: \"omit\",\n });\n } finally {\n forwarding = false;\n }\n}\n\n/**\n * Send one request's worth of buffered output, if nothing is in flight.\n *\n * The single-flight rule is what guarantees the dev server prints lines in the\n * order they were logged, and it doubles as backpressure: a slow endpoint makes\n * batches bigger rather than making requests pile up.\n */\nfunction flush(): void {\n if (requestInFlight) return;\n drainDroppedNotice();\n if (buffer.length === 0) return;\n clearFlushTimer();\n\n const batch = legacyEndpoint ? takeBatch(0) : takeBatch();\n if (batch.length === 0) return;\n\n const body = legacyEndpoint\n ? JSON.stringify(batch[0])\n : JSON.stringify({ entries: batch } satisfies DevLogBatch);\n\n requestInFlight = true;\n post(body).then(\n (response) => {\n requestInFlight = false;\n if (!response.ok) {\n countDropped(batch.length, `endpoint returned ${response.status}`);\n } else if (!legacyEndpoint && !response.headers.get(PROTOCOL_HEADER)) {\n adoptLegacyMode(batch);\n }\n if (buffer.length > 0 || droppedCount > 0) flush();\n },\n () => {\n requestInFlight = false;\n countDropped(batch.length, \"request failed\");\n if (buffer.length > 0) flush();\n },\n );\n}\n\nfunction countDropped(count: number, reason: string): void {\n droppedCount += count;\n droppedReason = reason;\n}\n\n/**\n * A 2xx with no protocol header came from an endpoint written before batching.\n * It parsed `{entries:[...]}` as a v1 body, found no `msg`, and printed one\n * empty line — so this batch is lost unless it is re-sent one line at a time,\n * and so is everything after it.\n */\nfunction adoptLegacyMode(lost: DevLogPayload[]): void {\n legacyEndpoint = true;\n const notice: DevLogPayload = {\n level: \"warn\",\n msg:\n \"[forge-devlog] this dev server's /__forge/log endpoint predates batched \" +\n \"posts; update it (forge skill: debug) — falling back to one request per line\",\n };\n buffer.unshift(notice, ...lost);\n bufferedBytes += [notice, ...lost].reduce((n, e) => n + entryBytes(e), 0);\n}\n\n/**\n * Last-chance flush on page teardown.\n *\n * fetch — even with keepalive — is not reliable here, and an async flush is\n * hopeless: the event loop may not run again. sendBeacon hands the bytes to the\n * browser to deliver after the document is gone, which is precisely the case\n * where the last few log lines explain what happened.\n */\nfunction flushOnUnload(): void {\n drainDroppedNotice();\n if (buffer.length === 0) return;\n\n const canBeacon =\n typeof navigator !== \"undefined\" && typeof navigator.sendBeacon === \"function\";\n if (!canBeacon) {\n // No beacon: keepalive fetch is the only remaining option, and one request\n // has a better chance of leaving than a queue of them.\n flush();\n return;\n }\n\n forwarding = true;\n try {\n while (buffer.length > 0) {\n const batch = legacyEndpoint ? takeBatch(0) : takeBatch();\n const body = legacyEndpoint\n ? JSON.stringify(batch[0])\n : JSON.stringify({ entries: batch } satisfies DevLogBatch);\n // text/plain is not a decoration: sendBeacon with application/json is a\n // non-simple request and would need a CORS preflight it cannot make. The\n // endpoints parse the body regardless of Content-Type for this reason.\n const blob = new Blob([body], { type: \"text/plain;charset=UTF-8\" });\n if (!navigator.sendBeacon(endpointUrl, blob)) {\n // The beacon queue is full (64 KiB per origin). Nothing later will fit\n // either, so stop and account for what is left.\n countDropped(batch.length + buffer.length, \"beacon queue full\");\n buffer = [];\n bufferedBytes = 0;\n return;\n }\n }\n } finally {\n forwarding = false;\n }\n}\n\nfunction onWindowError(event: ErrorEvent): void {\n capture(\"error\", [\n event.error ??\n `${event.message} @ ${event.filename}:${event.lineno}:${event.colno}`,\n ]);\n}\n\nfunction onRejection(event: PromiseRejectionEvent): void {\n capture(\"error\", [\"unhandled rejection:\", event.reason]);\n}\n\nlet errorHandler: ((e: ErrorEvent) => void) | undefined;\nlet rejectionHandler: ((e: PromiseRejectionEvent) => void) | undefined;\nlet pageHideHandler: (() => void) | undefined;\nlet visibilityHandler: (() => void) | undefined;\n\n/**\n * Mirror console output and uncaught errors to the dev server, which prints\n * them into .forge/logs/<env>/frontend_<name>.log.\n *\n * The window listeners are the point of the feature as much as the console\n * override is: an uncaught TypeError or a rejected promise produces no console\n * call of its own, and those are exactly the failures worth seeing.\n *\n * Safe to call more than once; the second call is a no-op.\n */\nexport function installDevLogging(options: DevLoggingOptions): void {\n // `dev` is read off `options` rather than destructured with a default, and\n // checked FIRST. A bundler constant-folds `import.meta.env.DEV` to false at\n // the CALL SITE and can then drop the argument entirely, leaving a bare\n // `installDevLogging({})` — with a defaulted `dev` that call would install\n // the override in a production bundle. `options?.dev !== true` is therefore\n // fail-closed: absent, undefined, or anything non-true means do nothing.\n if (options?.dev !== true) return;\n if (installed) return;\n if (typeof window === \"undefined\") return; // SSR / prerender pass\n\n const { endpoint = DEV_LOG_ENDPOINT, mirrorToConsole = true } = options;\n installed = true;\n endpointUrl = endpoint;\n buffer = [];\n bufferedBytes = 0;\n droppedCount = 0;\n droppedReason = \"\";\n legacyEndpoint = false;\n requestInFlight = false;\n\n for (const level of LEVELS) {\n // Keep the ORIGINAL reference, not a bound copy: uninstall must restore\n // console.log to the identical function it replaced, so a second consumer\n // holding that reference (or a test asserting on it) sees no difference.\n const previous = console[level] as (...args: unknown[]) => void;\n original[level] = previous;\n console[level] = (...args: unknown[]) => {\n if (mirrorToConsole) previous.apply(console, args);\n capture(level, args);\n };\n }\n\n errorHandler = (e) => onWindowError(e);\n rejectionHandler = (e) => onRejection(e);\n pageHideHandler = () => flushOnUnload();\n window.addEventListener(\"error\", errorHandler);\n window.addEventListener(\"unhandledrejection\", rejectionHandler);\n window.addEventListener(\"pagehide\", pageHideHandler);\n\n if (typeof document !== \"undefined\") {\n // visibilitychange→hidden is the only teardown signal mobile Safari\n // reliably fires; pagehide alone loses the whole buffer there.\n visibilityHandler = () => {\n if (document.visibilityState === \"hidden\") flushOnUnload();\n };\n document.addEventListener(\"visibilitychange\", visibilityHandler);\n }\n}\n\n/** Restore the original console methods and remove the window listeners. */\nexport function uninstallDevLogging(): void {\n if (!installed) return;\n\n // Flush before restoring: whatever is buffered was logged while this was\n // installed, and a teardown is no reason to lose it.\n clearFlushTimer();\n flush();\n\n for (const level of LEVELS) {\n const previous = original[level];\n if (previous) console[level] = previous;\n delete original[level];\n }\n if (errorHandler) window.removeEventListener(\"error\", errorHandler);\n if (rejectionHandler)\n window.removeEventListener(\"unhandledrejection\", rejectionHandler);\n if (pageHideHandler) window.removeEventListener(\"pagehide\", pageHideHandler);\n if (visibilityHandler && typeof document !== \"undefined\")\n document.removeEventListener(\"visibilitychange\", visibilityHandler);\n errorHandler = undefined;\n rejectionHandler = undefined;\n pageHideHandler = undefined;\n visibilityHandler = undefined;\n installed = false;\n}\n\n/** Whether the console override is currently installed. Exposed for tests. */\nexport function devLoggingInstalled(): boolean {\n return installed;\n}\n"]}
|
package/dist/service-hooks.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
1
2
|
// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
|
|
2
3
|
//
|
|
4
|
+
// Every export below is a React hook or builds one, so this module belongs in
|
|
5
|
+
// the CLIENT graph. Without the directive the Next.js App Router compiles it
|
|
6
|
+
// into the SERVER graph, where it binds a SECOND @tanstack/react-query
|
|
7
|
+
// instance and every generated hook throws "No QueryClient set" at runtime —
|
|
8
|
+
// invisible to tsc, eslint AND `next build`. published-surface.test.ts asserts
|
|
9
|
+
// the directive survives into dist/. It is NOT applied package-wide: the
|
|
10
|
+
// ./interceptors subpath exists to be importable without React.
|
|
11
|
+
//
|
|
3
12
|
// The React Query wrapper machinery behind forge's generated per-service hook
|
|
4
13
|
// files (`src/hooks/<svc>-service-hooks_gen.ts`).
|
|
5
14
|
//
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,8EAA8E;AAC9E,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;AACrE,qEAAqE;AACrE,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,sEAAsE;AACtE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,6EAA6E;AAC7E,4EAA4E;AAC5E,mCAAmC;AACnC,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,uDAAuD;AACvD,yEAAyE;AACzE,8EAA8E;AAC9E,8EAA8E;AAC9E,OAAO,EACL,MAAM,EACN,MAAM,GAGP,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,cAAc,GAKf,MAAM,uBAAuB,CAAC;AAW/B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,UAAU,CACxB,MAAc,EACd,GAA6B;IAE7B,OAAO,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAoBD;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO;QACL,GAAG,EAAE,CAAC,OAAO,CAAU;QACvB,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,CAAU;QAClD,KAAK,EACH,CACE,MAAc,EACd,MAAc,EACd,WAAoB,EACpB,EAAE,CACJ,CAAC,GAA6B,EAAmB,EAAE,CACjD,WAAW,KAAK,SAAS;YACvB,CAAC,CAAE,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;YACvD,CAAC,CAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;KAC3E,CAAC;AACJ,CAAC;AAeD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,MAA0D,EAC1D,IAA0D;IAE1D,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,CACtB,QAAQ,CAAC;QACP,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC;QACrB,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;QACxB,GAAG,OAAO;KACX,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,kBAAkB,CAChC,aAA8B,EAC9B,IAA0D;IAE1D,OAAO,CAAC,OAAO,EAAE,EAAE;QACjB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;QACrC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,IAAI,EAAE,CAAC;QAC7C,OAAO,WAAW,CAAC;YACjB,UAAU,EAAE,CAAC,GAA6B,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;YACxD,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE;gBACrB,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,CAAC,CAAC;gBAC3D,SAAS,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACvB,CAAC;YACD,GAAG,IAAI;SACR,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The React Query wrapper machinery behind forge's generated per-service hook\n// files (`src/hooks/<svc>-service-hooks_gen.ts`).\n//\n// WHAT WAS HERE BEFORE. Each generated file re-emitted the same three things\n// per RPC: a query-key entry, a useQuery or useMutation wrapper, and — for\n// mutations — the entity-scoped invalidateQueries plus the onSuccess\n// composition, with the reasoning for each re-inlined as comments. A\n// five-service project rendered five copies of machinery that differed only in\n// RPC names and message types. This module IS that machinery, written once.\n// The generated file keeps exactly what varies per RPC: the name, the request\n// schema, the response type, query-vs-mutation, and the entity scope.\n//\n// WHY A SUBPATH AND NOT THE BARREL. This module imports @tanstack/react-query.\n// Every browser frontend forge scaffolds depends on it — but the barrel is\n// also what a consumer imports for interceptors and errors alone, and\n// anchoring React Query there would make a non-React consumer resolve a\n// package it never installed. Same reasoning as ./mock-transport and ./otel:\n// an engine with its own dependency footprint gets its own entry point, and\n// React Query is an OPTIONAL peer.\n//\n// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — the\n// app-wide MutationCache in src/lib/query-client.ts is the single error-toast\n// chokepoint, and pages that render errors inline pass\n// `meta: { silenceErrorToast: true }`. It never defaults success-side UX\n// either: \"CreateOrder succeeded\" leaks RPC names into the UI, so the toast /\n// redirect / inline confirmation stays the call site's via options.onSuccess.\nimport {\n create,\n toJson,\n type DescMessage,\n type MessageInitShape,\n} from \"@bufbuild/protobuf\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationOptions,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\n\nimport type { ConnectClientError } from \"./errors.js\";\n\n/**\n * A generated query key. The tuple is [service, entity?, method,\n * protojson(req)] for a method key and [service] / [service, entity] for the\n * broader scopes.\n */\nexport type ServiceQueryKey = readonly unknown[];\n\n/**\n * requestKey projects a request init into protojson for use inside a query\n * key. Two reasons this is not the raw request object:\n *\n * - React Query's default hasher is JSON.stringify, which THROWS on bigint\n * fields (any proto int64) — the raw message crashes the cache.\n * - protojson normalizes representations, so { id: 1 } and { id: \"1\" } land\n * in ONE cache entry instead of silently splitting the cache.\n *\n * Exported because a hand-written hook that wants to share a generated hook's\n * cache entry has to hash its request the identical way.\n */\nexport function requestKey<Schema extends DescMessage>(\n schema: Schema,\n req: MessageInitShape<Schema>,\n): unknown {\n return toJson(schema, create(schema, req));\n}\n\n/** The three key-scope builders a generated key factory is assembled from. */\nexport interface ServiceKeyBuilders {\n /** Every query on this service: [service]. */\n readonly all: readonly [string];\n /** Every query for one CRUD entity: [service, entity]. Mutations invalidate this. */\n entity(name: string): readonly [string, string];\n /**\n * One query: [service, entity?, method, protojson(req)]. Returns the\n * per-request key FUNCTION, which is what both the query hook and any\n * call-site prefetch/setQueryData use.\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: MessageInitShape<Schema>) => ServiceQueryKey;\n}\n\n/**\n * serviceKeys returns the builders for one service's query-key factory.\n *\n * Cache-key semantics are a compatibility contract, not an implementation\n * detail: an existing app's\n * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep\n * matching the keys its queries were registered under across a version bump.\n * So the tuple order and contents produced here are fixed, and identical to\n * what forge previously inlined into every generated hook file.\n */\nexport function serviceKeys(service: string): ServiceKeyBuilders {\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: MessageInitShape<Schema>): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method, requestKey(schema, req)] as const)\n : ([service, entityScope, method, requestKey(schema, req)] as const),\n };\n}\n\n/** A generated query hook: `useGetOrder(req, options?)`. */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n options?: Partial<UseQueryOptions<Response, ConnectClientError>>,\n) => UseQueryResult<Response, ConnectClientError>;\n\n/** A generated mutation hook: `useCreateOrder(options?)`. */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = (\n options?: Partial<\n UseMutationOptions<Response, ConnectClientError, MessageInitShape<Schema>>\n >,\n) => UseMutationResult<Response, ConnectClientError, MessageInitShape<Schema>>;\n\n/**\n * createQueryHook builds one useQuery wrapper.\n *\n * The signature it returns is the one call sites already use — `(req,\n * options?)`, with the error typed as ConnectClientError rather than React\n * Query's default `Error`. That type is declared on the OPTIONS parameter\n * because React Query takes the result's error type from the same TError it\n * reads there, so `query.error` is a ConnectClientError at every call site\n * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`\n * and `.retryable`, and left `err.message.includes(...)` as the only\n * expression that compiled — the one thing the error contract forbids.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceQueryHook<Schema, Response> {\n return (req, options) =>\n useQuery({\n queryKey: keyFor(req),\n queryFn: () => call(req),\n ...options,\n });\n}\n\n/**\n * createMutationHook builds one useMutation wrapper with scoped invalidation.\n *\n * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and\n * the whole-service scope otherwise — a bespoke mutation may touch anything,\n * so over-invalidating is the safe default there.\n *\n * The invalidation is composed BEFORE the caller's onSuccess and is never\n * replaced by it: onSuccess is destructured out and re-invoked after the\n * invalidate, while the rest of the options object is spread separately. A\n * caller passing onSuccess therefore ADDS behaviour, rather than silently\n * disabling the cache refresh — which is exactly what a plain `...options`\n * spread over a default onSuccess would do.\n */\nexport function createMutationHook<Schema extends DescMessage, Response>(\n invalidateKey: ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceMutationHook<Schema, Response> {\n return (options) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation({\n mutationFn: (req: MessageInitShape<Schema>) => call(req),\n onSuccess: (...args) => {\n queryClient.invalidateQueries({ queryKey: invalidateKey });\n onSuccess?.(...args);\n },\n ...rest,\n });\n };\n}\n"]}
|
|
1
|
+
{"version":3,"file":"service-hooks.js","sourceRoot":"","sources":["../src/service-hooks.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,sEAAsE;AACtE,EAAE;AACF,8EAA8E;AAC9E,6EAA6E;AAC7E,uEAAuE;AACvE,6EAA6E;AAC7E,+EAA+E;AAC/E,yEAAyE;AACzE,gEAAgE;AAChE,EAAE;AACF,8EAA8E;AAC9E,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;AACrE,qEAAqE;AACrE,+EAA+E;AAC/E,4EAA4E;AAC5E,8EAA8E;AAC9E,sEAAsE;AACtE,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,wEAAwE;AACxE,6EAA6E;AAC7E,4EAA4E;AAC5E,mCAAmC;AACnC,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,uDAAuD;AACvD,yEAAyE;AACzE,8EAA8E;AAC9E,8EAA8E;AAC9E,OAAO,EACL,MAAM,EACN,MAAM,GAGP,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,WAAW,EACX,QAAQ,EACR,cAAc,GAKf,MAAM,uBAAuB,CAAC;AAW/B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,UAAU,CACxB,MAAc,EACd,GAA6B;IAE7B,OAAO,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAoBD;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO;QACL,GAAG,EAAE,CAAC,OAAO,CAAU;QACvB,MAAM,EAAE,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,CAAU;QAClD,KAAK,EACH,CACE,MAAc,EACd,MAAc,EACd,WAAoB,EACpB,EAAE,CACJ,CAAC,GAA6B,EAAmB,EAAE,CACjD,WAAW,KAAK,SAAS;YACvB,CAAC,CAAE,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;YACvD,CAAC,CAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,GAAG,CAAC,CAAW;KAC3E,CAAC;AACJ,CAAC;AAeD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,eAAe,CAC7B,MAA0D,EAC1D,IAA0D;IAE1D,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,EAAE,CACtB,QAAQ,CAAC;QACP,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAC;QACrB,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;QACxB,GAAG,OAAO;KACX,CAAC,CAAC;AACP,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,kBAAkB,CAChC,aAA8B,EAC9B,IAA0D;IAE1D,OAAO,CAAC,OAAO,EAAE,EAAE;QACjB,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;QACrC,MAAM,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,GAAG,OAAO,IAAI,EAAE,CAAC;QAC7C,OAAO,WAAW,CAAC;YACjB,UAAU,EAAE,CAAC,GAA6B,EAAE,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC;YACxD,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,EAAE;gBACrB,WAAW,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,aAAa,EAAE,CAAC,CAAC;gBAC3D,SAAS,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACvB,CAAC;YACD,GAAG,IAAI;SACR,CAAC,CAAC;IACL,CAAC,CAAC;AACJ,CAAC","sourcesContent":["\"use client\";\n\n// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Every export below is a React hook or builds one, so this module belongs in\n// the CLIENT graph. Without the directive the Next.js App Router compiles it\n// into the SERVER graph, where it binds a SECOND @tanstack/react-query\n// instance and every generated hook throws \"No QueryClient set\" at runtime —\n// invisible to tsc, eslint AND `next build`. published-surface.test.ts asserts\n// the directive survives into dist/. It is NOT applied package-wide: the\n// ./interceptors subpath exists to be importable without React.\n//\n// The React Query wrapper machinery behind forge's generated per-service hook\n// files (`src/hooks/<svc>-service-hooks_gen.ts`).\n//\n// WHAT WAS HERE BEFORE. Each generated file re-emitted the same three things\n// per RPC: a query-key entry, a useQuery or useMutation wrapper, and — for\n// mutations — the entity-scoped invalidateQueries plus the onSuccess\n// composition, with the reasoning for each re-inlined as comments. A\n// five-service project rendered five copies of machinery that differed only in\n// RPC names and message types. This module IS that machinery, written once.\n// The generated file keeps exactly what varies per RPC: the name, the request\n// schema, the response type, query-vs-mutation, and the entity scope.\n//\n// WHY A SUBPATH AND NOT THE BARREL. This module imports @tanstack/react-query.\n// Every browser frontend forge scaffolds depends on it — but the barrel is\n// also what a consumer imports for interceptors and errors alone, and\n// anchoring React Query there would make a non-React consumer resolve a\n// package it never installed. Same reasoning as ./mock-transport and ./otel:\n// an engine with its own dependency footprint gets its own entry point, and\n// React Query is an OPTIONAL peer.\n//\n// WHAT IT DELIBERATELY DOES NOT DO. It does not emit error toasts — the\n// app-wide MutationCache in src/lib/query-client.ts is the single error-toast\n// chokepoint, and pages that render errors inline pass\n// `meta: { silenceErrorToast: true }`. It never defaults success-side UX\n// either: \"CreateOrder succeeded\" leaks RPC names into the UI, so the toast /\n// redirect / inline confirmation stays the call site's via options.onSuccess.\nimport {\n create,\n toJson,\n type DescMessage,\n type MessageInitShape,\n} from \"@bufbuild/protobuf\";\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationOptions,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from \"@tanstack/react-query\";\n\nimport type { ConnectClientError } from \"./errors.js\";\n\n/**\n * A generated query key. The tuple is [service, entity?, method,\n * protojson(req)] for a method key and [service] / [service, entity] for the\n * broader scopes.\n */\nexport type ServiceQueryKey = readonly unknown[];\n\n/**\n * requestKey projects a request init into protojson for use inside a query\n * key. Two reasons this is not the raw request object:\n *\n * - React Query's default hasher is JSON.stringify, which THROWS on bigint\n * fields (any proto int64) — the raw message crashes the cache.\n * - protojson normalizes representations, so { id: 1 } and { id: \"1\" } land\n * in ONE cache entry instead of silently splitting the cache.\n *\n * Exported because a hand-written hook that wants to share a generated hook's\n * cache entry has to hash its request the identical way.\n */\nexport function requestKey<Schema extends DescMessage>(\n schema: Schema,\n req: MessageInitShape<Schema>,\n): unknown {\n return toJson(schema, create(schema, req));\n}\n\n/** The three key-scope builders a generated key factory is assembled from. */\nexport interface ServiceKeyBuilders {\n /** Every query on this service: [service]. */\n readonly all: readonly [string];\n /** Every query for one CRUD entity: [service, entity]. Mutations invalidate this. */\n entity(name: string): readonly [string, string];\n /**\n * One query: [service, entity?, method, protojson(req)]. Returns the\n * per-request key FUNCTION, which is what both the query hook and any\n * call-site prefetch/setQueryData use.\n */\n query<Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ): (req: MessageInitShape<Schema>) => ServiceQueryKey;\n}\n\n/**\n * serviceKeys returns the builders for one service's query-key factory.\n *\n * Cache-key semantics are a compatibility contract, not an implementation\n * detail: an existing app's\n * `invalidateQueries({ queryKey: orderServiceKeys.order })` has to keep\n * matching the keys its queries were registered under across a version bump.\n * So the tuple order and contents produced here are fixed, and identical to\n * what forge previously inlined into every generated hook file.\n */\nexport function serviceKeys(service: string): ServiceKeyBuilders {\n return {\n all: [service] as const,\n entity: (name: string) => [service, name] as const,\n query:\n <Schema extends DescMessage>(\n method: string,\n schema: Schema,\n entityScope?: string,\n ) =>\n (req: MessageInitShape<Schema>): ServiceQueryKey =>\n entityScope === undefined\n ? ([service, method, requestKey(schema, req)] as const)\n : ([service, entityScope, method, requestKey(schema, req)] as const),\n };\n}\n\n/** A generated query hook: `useGetOrder(req, options?)`. */\nexport type ServiceQueryHook<Schema extends DescMessage, Response> = (\n req: MessageInitShape<Schema>,\n options?: Partial<UseQueryOptions<Response, ConnectClientError>>,\n) => UseQueryResult<Response, ConnectClientError>;\n\n/** A generated mutation hook: `useCreateOrder(options?)`. */\nexport type ServiceMutationHook<Schema extends DescMessage, Response> = (\n options?: Partial<\n UseMutationOptions<Response, ConnectClientError, MessageInitShape<Schema>>\n >,\n) => UseMutationResult<Response, ConnectClientError, MessageInitShape<Schema>>;\n\n/**\n * createQueryHook builds one useQuery wrapper.\n *\n * The signature it returns is the one call sites already use — `(req,\n * options?)`, with the error typed as ConnectClientError rather than React\n * Query's default `Error`. That type is declared on the OPTIONS parameter\n * because React Query takes the result's error type from the same TError it\n * reads there, so `query.error` is a ConnectClientError at every call site\n * with nothing to pass. Typing it as `Error` hid `.reason`, `.code`, `.status`\n * and `.retryable`, and left `err.message.includes(...)` as the only\n * expression that compiled — the one thing the error contract forbids.\n */\nexport function createQueryHook<Schema extends DescMessage, Response>(\n keyFor: (req: MessageInitShape<Schema>) => ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceQueryHook<Schema, Response> {\n return (req, options) =>\n useQuery({\n queryKey: keyFor(req),\n queryFn: () => call(req),\n ...options,\n });\n}\n\n/**\n * createMutationHook builds one useMutation wrapper with scoped invalidation.\n *\n * invalidateKey is the entity scope when the RPC maps to a CRUD entity, and\n * the whole-service scope otherwise — a bespoke mutation may touch anything,\n * so over-invalidating is the safe default there.\n *\n * The invalidation is composed BEFORE the caller's onSuccess and is never\n * replaced by it: onSuccess is destructured out and re-invoked after the\n * invalidate, while the rest of the options object is spread separately. A\n * caller passing onSuccess therefore ADDS behaviour, rather than silently\n * disabling the cache refresh — which is exactly what a plain `...options`\n * spread over a default onSuccess would do.\n */\nexport function createMutationHook<Schema extends DescMessage, Response>(\n invalidateKey: ServiceQueryKey,\n call: (req: MessageInitShape<Schema>) => Promise<Response>,\n): ServiceMutationHook<Schema, Response> {\n return (options) => {\n const queryClient = useQueryClient();\n const { onSuccess, ...rest } = options ?? {};\n return useMutation({\n mutationFn: (req: MessageInitShape<Schema>) => call(req),\n onSuccess: (...args) => {\n queryClient.invalidateQueries({ queryKey: invalidateKey });\n onSuccess?.(...args);\n },\n ...rest,\n });\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reliantlabs/forge-web-runtime",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "The forge frontend runtime: Connect interceptor stack, session, error boundary, toast queue, <Resource> container, W3C trace propagation and client RUM. The web twin of forge/pkg.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"//repository note": "REQUIRED for `npm publish --provenance`, not decoration. npm cross-checks this against the repository in the signed provenance statement and rejects the publish with a 422 if they disagree — an absent field reads as \"\" and fails that comparison. `directory` names the subdirectory this package lives in, so the provenance points at the real source. Omitting this is what made the first 0.3.1 publish fail AFTER the tarball had been signed and written to the sigstore transparency log.",
|
|
@@ -62,13 +62,13 @@
|
|
|
62
62
|
"@bufbuild/protobuf": "^2.5.0",
|
|
63
63
|
"@connectrpc/connect": "^2.0.0",
|
|
64
64
|
"@opentelemetry/api": "^1.9.0",
|
|
65
|
-
"@opentelemetry/auto-instrumentations-web": "^0.
|
|
66
|
-
"@opentelemetry/core": "^2.
|
|
67
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
68
|
-
"@opentelemetry/instrumentation": "^0.
|
|
69
|
-
"@opentelemetry/resources": "^2.
|
|
70
|
-
"@opentelemetry/sdk-trace-base": "^2.
|
|
71
|
-
"@opentelemetry/sdk-trace-web": "^2.
|
|
65
|
+
"@opentelemetry/auto-instrumentations-web": "^0.67.0",
|
|
66
|
+
"@opentelemetry/core": "^2.8.0",
|
|
67
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
68
|
+
"@opentelemetry/instrumentation": "^0.222.0",
|
|
69
|
+
"@opentelemetry/resources": "^2.8.0",
|
|
70
|
+
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
|
71
|
+
"@opentelemetry/sdk-trace-web": "^2.8.0",
|
|
72
72
|
"@opentelemetry/semantic-conventions": "^1.28.0",
|
|
73
73
|
"@tanstack/react-query": "^5.59.0",
|
|
74
74
|
"react": "^18.3.0 || ^19.0.0"
|
|
@@ -106,13 +106,13 @@
|
|
|
106
106
|
"@bufbuild/protobuf": "^2.5.0",
|
|
107
107
|
"@connectrpc/connect": "^2.0.0",
|
|
108
108
|
"@opentelemetry/api": "^1.9.0",
|
|
109
|
-
"@opentelemetry/auto-instrumentations-web": "^0.
|
|
110
|
-
"@opentelemetry/core": "^2.
|
|
111
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
112
|
-
"@opentelemetry/instrumentation": "^0.
|
|
113
|
-
"@opentelemetry/resources": "^2.
|
|
114
|
-
"@opentelemetry/sdk-trace-base": "^2.
|
|
115
|
-
"@opentelemetry/sdk-trace-web": "^2.
|
|
109
|
+
"@opentelemetry/auto-instrumentations-web": "^0.67.0",
|
|
110
|
+
"@opentelemetry/core": "^2.8.0",
|
|
111
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
|
|
112
|
+
"@opentelemetry/instrumentation": "^0.222.0",
|
|
113
|
+
"@opentelemetry/resources": "^2.8.0",
|
|
114
|
+
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
|
115
|
+
"@opentelemetry/sdk-trace-web": "^2.8.0",
|
|
116
116
|
"@opentelemetry/semantic-conventions": "^1.28.0",
|
|
117
117
|
"@tanstack/react-query": "^5.59.0",
|
|
118
118
|
"@types/node": "^22.14.0",
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
"@types/react-18": "npm:@types/react@~18.3.0",
|
|
121
121
|
"react": "^19.1.0",
|
|
122
122
|
"typescript": "^5.8.0",
|
|
123
|
-
"vitest": "^
|
|
123
|
+
"vitest": "^4.1.11"
|
|
124
124
|
},
|
|
125
125
|
"engines": {
|
|
126
126
|
"node": ">=22"
|