nucleus-core-ts 0.9.992 → 0.9.993
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/bin/cli.ts +131 -131
- package/dist/.build-ok +1 -1
- package/dist/client.js +1 -1
- package/dist/index.js +1 -1
- package/dist/src/Client/ApiCaller/client.d.ts +1 -0
- package/dist/src/Client/Proxy/httpProxy.js +88 -88
- package/dist/src/Client/Proxy/synthesizedAuth.js +10 -10
- package/dist/src/Client/Proxy/wsProxy.js +41 -41
- package/dist/src/Client/PubSub/usePubSub.js +97 -97
- package/dist/src/Client/PubSub/waitForReturn.js +29 -29
- package/dist/src/Client/Upload/progress.js +31 -31
- package/dist/src/Client/Upload/types.js +7 -7
- package/dist/src/Client/Upload/upload.js +21 -21
- package/package.json +1 -1
- package/scripts/generate-schema.ts +739 -739
- package/src/system.tables.json +5527 -5527
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ApiResponse, EndpointAction, EndpointDefinition } from './types';
|
|
2
|
+
export declare function createActionsFacade<TActions extends object>(endpoints: Record<string, unknown>, buildAction: (key: string) => TActions[keyof TActions]): TActions;
|
|
2
3
|
export declare function createApiHook<TEndpoints extends Record<string, EndpointDefinition>>(endpoints: TEndpoints, factory: <K extends keyof TEndpoints & string>(endpointKey: K, payload: TEndpoints[K]['_payload']) => Promise<ApiResponse<TEndpoints[K]['_success'], TEndpoints[K]['_error']>>): () => { [K in keyof TEndpoints]: EndpointAction<TEndpoints[K]["_payload"], TEndpoints[K]["_success"], TEndpoints[K]["_error"]>; };
|
|
3
4
|
export type ApiHook<TEndpoints extends Record<string, EndpointDefinition>> = ReturnType<typeof createApiHook<TEndpoints>>;
|
|
4
5
|
export type ApiActions<TEndpoints extends Record<string, EndpointDefinition>> = ReturnType<ApiHook<TEndpoints>>;
|
|
@@ -135,11 +135,11 @@ function appendSetCookieHeaders(responseHeaders, setCookieHeaders) {
|
|
|
135
135
|
responseHeaders.append('Set-Cookie', cookie);
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
-
/**
|
|
139
|
-
* Preserve individual Set-Cookie headers when copying via new Headers().
|
|
140
|
-
* new Headers(response.headers) may combine multiple Set-Cookie headers
|
|
141
|
-
* into a single comma-separated value — browsers cannot parse this for
|
|
142
|
-
* Set-Cookie (RFC 6265). This function re-adds them individually.
|
|
138
|
+
/**
|
|
139
|
+
* Preserve individual Set-Cookie headers when copying via new Headers().
|
|
140
|
+
* new Headers(response.headers) may combine multiple Set-Cookie headers
|
|
141
|
+
* into a single comma-separated value — browsers cannot parse this for
|
|
142
|
+
* Set-Cookie (RFC 6265). This function re-adds them individually.
|
|
143
143
|
*/ function preserveSetCookieHeaders(target, source) {
|
|
144
144
|
const setCookies = typeof source.getSetCookie === 'function' ? source.getSetCookie() : [];
|
|
145
145
|
if (setCookies.length > 1) {
|
|
@@ -149,10 +149,10 @@ function appendSetCookieHeaders(responseHeaders, setCookieHeaders) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Resolve the user id to inject as x-user-id from a bearer token, or null if it can't be
|
|
154
|
-
* TRUSTED. Fail-closed: returns null unless BOTH a secret and a token are present AND the token
|
|
155
|
-
* passes HS256 signature + expiry verification. Never trusts a bare/forged/unsigned token.
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the user id to inject as x-user-id from a bearer token, or null if it can't be
|
|
154
|
+
* TRUSTED. Fail-closed: returns null unless BOTH a secret and a token are present AND the token
|
|
155
|
+
* passes HS256 signature + expiry verification. Never trusts a bare/forged/unsigned token.
|
|
156
156
|
*/ export function resolveInjectedUserId(token, secret) {
|
|
157
157
|
if (!secret || !token) return null;
|
|
158
158
|
const payload = verifyJwtHS256(token, secret);
|
|
@@ -179,9 +179,9 @@ export function createHttpProxyHandler(config) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
-
/**
|
|
183
|
-
* Claim-gate a request for a target. Returns a 403 Response to block (enforce
|
|
184
|
-
* mode), or null to allow (also null in audit mode, where a would-block is logged).
|
|
182
|
+
/**
|
|
183
|
+
* Claim-gate a request for a target. Returns a 403 Response to block (enforce
|
|
184
|
+
* mode), or null to allow (also null in audit mode, where a would-block is logged).
|
|
185
185
|
*/ async function checkAuthorize(req, target, path) {
|
|
186
186
|
const az = target.authorize;
|
|
187
187
|
if (!az) return null;
|
|
@@ -369,30 +369,30 @@ export function createHttpProxyHandler(config) {
|
|
|
369
369
|
function injectToken(headers, token, headerName) {
|
|
370
370
|
headers.set(headerName, `Bearer ${token}`);
|
|
371
371
|
}
|
|
372
|
-
/**
|
|
373
|
-
* Forward one request upstream, bounded by BOTH clocks that can end it.
|
|
374
|
-
*
|
|
375
|
-
* The timeout below only bounds time-to-first-headers: it is cleared the
|
|
376
|
-
* moment headers arrive, because a streamed body (SSE, a long download, a
|
|
377
|
-
* model's token stream) legitimately takes as long as it takes. That is
|
|
378
|
-
* correct — but on its own it left a streamed request with NO way to end.
|
|
379
|
-
*
|
|
380
|
-
* So when the person closed the tab, the browser's connection went away and
|
|
381
|
-
* ours did not. The upstream request stayed open, holding one of the
|
|
382
|
-
* runtime's outgoing connection slots, forever.
|
|
383
|
-
*
|
|
384
|
-
* That is not a slow leak. Measured on Bun 1.3.14: 256 clients that connect
|
|
385
|
-
* and hang up leave 256 upstream requests open (0 cancelled), and the process
|
|
386
|
-
* can then make no outgoing request at all — every later fetch times out.
|
|
387
|
-
* A single tab reconnecting an EventSource 256 times does it alone.
|
|
388
|
-
*
|
|
389
|
-
* Cancelling the body we hand back (`body.cancel()`) does NOT release the
|
|
390
|
-
* slot; only aborting the upstream fetch does. Measured both ways.
|
|
391
|
-
*
|
|
392
|
-
* Hence `clientSignal`: the client's hangup is wired to the same controller
|
|
393
|
-
* the timeout uses, so whichever ends first ends the upstream request. The
|
|
394
|
-
* listener is left attached deliberately — it belongs to this one request and
|
|
395
|
-
* is collected with it, and aborting an already-finished fetch is a no-op.
|
|
372
|
+
/**
|
|
373
|
+
* Forward one request upstream, bounded by BOTH clocks that can end it.
|
|
374
|
+
*
|
|
375
|
+
* The timeout below only bounds time-to-first-headers: it is cleared the
|
|
376
|
+
* moment headers arrive, because a streamed body (SSE, a long download, a
|
|
377
|
+
* model's token stream) legitimately takes as long as it takes. That is
|
|
378
|
+
* correct — but on its own it left a streamed request with NO way to end.
|
|
379
|
+
*
|
|
380
|
+
* So when the person closed the tab, the browser's connection went away and
|
|
381
|
+
* ours did not. The upstream request stayed open, holding one of the
|
|
382
|
+
* runtime's outgoing connection slots, forever.
|
|
383
|
+
*
|
|
384
|
+
* That is not a slow leak. Measured on Bun 1.3.14: 256 clients that connect
|
|
385
|
+
* and hang up leave 256 upstream requests open (0 cancelled), and the process
|
|
386
|
+
* can then make no outgoing request at all — every later fetch times out.
|
|
387
|
+
* A single tab reconnecting an EventSource 256 times does it alone.
|
|
388
|
+
*
|
|
389
|
+
* Cancelling the body we hand back (`body.cancel()`) does NOT release the
|
|
390
|
+
* slot; only aborting the upstream fetch does. Measured both ways.
|
|
391
|
+
*
|
|
392
|
+
* Hence `clientSignal`: the client's hangup is wired to the same controller
|
|
393
|
+
* the timeout uses, so whichever ends first ends the upstream request. The
|
|
394
|
+
* listener is left attached deliberately — it belongs to this one request and
|
|
395
|
+
* is collected with it, and aborting an already-finished fetch is a no-op.
|
|
396
396
|
*/ async function forwardRequest(finalUrl, method, headers, body, timeout, followRedirects, clientSignal) {
|
|
397
397
|
const controller = new AbortController();
|
|
398
398
|
const timeoutId = setTimeout(()=>controller.abort(), timeout);
|
|
@@ -408,13 +408,13 @@ export function createHttpProxyHandler(config) {
|
|
|
408
408
|
clientGone = true;
|
|
409
409
|
clearTimeout(timeoutId);
|
|
410
410
|
controller.abort();
|
|
411
|
-
/*
|
|
412
|
-
* Logged HERE, not in the catch below, because the common case
|
|
413
|
-
* never reaches the catch: once headers have arrived this function
|
|
414
|
-
* has already returned, and the hangup happens while the body is
|
|
415
|
-
* still streaming. That is exactly the case that used to leak, so
|
|
416
|
-
* it is exactly the case that has to be visible — a cancellation
|
|
417
|
-
* nobody can see is how this went unnoticed for months.
|
|
411
|
+
/*
|
|
412
|
+
* Logged HERE, not in the catch below, because the common case
|
|
413
|
+
* never reaches the catch: once headers have arrived this function
|
|
414
|
+
* has already returned, and the hangup happens while the body is
|
|
415
|
+
* still streaming. That is exactly the case that used to leak, so
|
|
416
|
+
* it is exactly the case that has to be visible — a cancellation
|
|
417
|
+
* nobody can see is how this went unnoticed for months.
|
|
418
418
|
*/ logger.info(`Client disconnected, upstream cancelled: ${finalUrl}`);
|
|
419
419
|
}, {
|
|
420
420
|
once: true
|
|
@@ -508,15 +508,15 @@ export function createHttpProxyHandler(config) {
|
|
|
508
508
|
status: 401,
|
|
509
509
|
headers: {
|
|
510
510
|
'Content-Type': 'application/json',
|
|
511
|
-
/*
|
|
512
|
-
* This 401 is OURS, not the API's. The API was never asked —
|
|
513
|
-
* the refresh call to it is what failed, and it fails for the
|
|
514
|
-
* same reason everything else does when the backend is down.
|
|
515
|
-
*
|
|
516
|
-
* Saying so matters: a client that reads an unqualified 401 as
|
|
517
|
-
* "your session is over" turns one pod restart into a sign-in
|
|
518
|
-
* prompt for somebody whose session has six more days on it.
|
|
519
|
-
* That is exactly what happened to the realtime bar.
|
|
511
|
+
/*
|
|
512
|
+
* This 401 is OURS, not the API's. The API was never asked —
|
|
513
|
+
* the refresh call to it is what failed, and it fails for the
|
|
514
|
+
* same reason everything else does when the backend is down.
|
|
515
|
+
*
|
|
516
|
+
* Saying so matters: a client that reads an unqualified 401 as
|
|
517
|
+
* "your session is over" turns one pod restart into a sign-in
|
|
518
|
+
* prompt for somebody whose session has six more days on it.
|
|
519
|
+
* That is exactly what happened to the realtime bar.
|
|
520
520
|
*/ [SYNTHESIZED_AUTH_HEADER]: 'refresh-failed'
|
|
521
521
|
}
|
|
522
522
|
});
|
|
@@ -530,30 +530,30 @@ export function createHttpProxyHandler(config) {
|
|
|
530
530
|
logger.warn(`[Auth:proxy] NO TOKEN available for ${path} — request will likely fail with 401`);
|
|
531
531
|
}
|
|
532
532
|
const baseTimeout = target.timeout ?? 30000;
|
|
533
|
-
/*
|
|
534
|
-
* A file needs minutes, not thirty seconds.
|
|
535
|
-
*
|
|
536
|
-
* The fixed timeout aborted a 200 MB video long before the last byte, and
|
|
537
|
-
* the browser had no way to tell that from a slow line — the bar simply
|
|
538
|
-
* stopped. The allowance now grows with the declared size.
|
|
533
|
+
/*
|
|
534
|
+
* A file needs minutes, not thirty seconds.
|
|
535
|
+
*
|
|
536
|
+
* The fixed timeout aborted a 200 MB video long before the last byte, and
|
|
537
|
+
* the browser had no way to tell that from a slow line — the bar simply
|
|
538
|
+
* stopped. The allowance now grows with the declared size.
|
|
539
539
|
*/ const timeout = timeoutForBody(req.headers, baseTimeout);
|
|
540
540
|
const followRedirects = target.followRedirects !== false;
|
|
541
541
|
// Buffer body for potential retry (only if 401 retry is enabled)
|
|
542
542
|
let bodyForRetry = null;
|
|
543
543
|
let bodyToSend = req.body;
|
|
544
|
-
/*
|
|
545
|
-
* Only a body small enough to hold.
|
|
546
|
-
*
|
|
547
|
-
* Buffering exists so a 401 can be answered by refreshing the token and
|
|
548
|
-
* sending the same request again — worth it for a form post, fatal for a
|
|
549
|
-
* file. An 800 MB video was read into an ArrayBuffer inside a container
|
|
550
|
-
* limited to 256 MiB: the upload appeared to run, because the browser
|
|
551
|
-
* really was sending bytes, and then died with nothing to show for it.
|
|
552
|
-
*
|
|
553
|
-
* A large body streams straight through and gives up the retry. That is
|
|
554
|
-
* the right way round: an upload that is refused can be started again by
|
|
555
|
-
* the person who chose the file; an upload that never completes cannot be
|
|
556
|
-
* rescued by anybody.
|
|
544
|
+
/*
|
|
545
|
+
* Only a body small enough to hold.
|
|
546
|
+
*
|
|
547
|
+
* Buffering exists so a 401 can be answered by refreshing the token and
|
|
548
|
+
* sending the same request again — worth it for a form post, fatal for a
|
|
549
|
+
* file. An 800 MB video was read into an ArrayBuffer inside a container
|
|
550
|
+
* limited to 256 MiB: the upload appeared to run, because the browser
|
|
551
|
+
* really was sending bytes, and then died with nothing to show for it.
|
|
552
|
+
*
|
|
553
|
+
* A large body streams straight through and gives up the retry. That is
|
|
554
|
+
* the right way round: an upload that is refused can be started again by
|
|
555
|
+
* the person who chose the file; an upload that never completes cannot be
|
|
556
|
+
* rescued by anybody.
|
|
557
557
|
*/ const bufferable = canBufferForRetry(req.headers);
|
|
558
558
|
if (refreshEnabled && (refreshConfig.retryOn401 ?? true) && req.body && bufferable) {
|
|
559
559
|
try {
|
|
@@ -605,15 +605,15 @@ export function createHttpProxyHandler(config) {
|
|
|
605
605
|
status: 401,
|
|
606
606
|
headers: {
|
|
607
607
|
'Content-Type': 'application/json',
|
|
608
|
-
/*
|
|
609
|
-
* This 401 is OURS, not the API's. The API was never asked —
|
|
610
|
-
* the refresh call to it is what failed, and it fails for the
|
|
611
|
-
* same reason everything else does when the backend is down.
|
|
612
|
-
*
|
|
613
|
-
* Saying so matters: a client that reads an unqualified 401 as
|
|
614
|
-
* "your session is over" turns one pod restart into a sign-in
|
|
615
|
-
* prompt for somebody whose session has six more days on it.
|
|
616
|
-
* That is exactly what happened to the realtime bar.
|
|
608
|
+
/*
|
|
609
|
+
* This 401 is OURS, not the API's. The API was never asked —
|
|
610
|
+
* the refresh call to it is what failed, and it fails for the
|
|
611
|
+
* same reason everything else does when the backend is down.
|
|
612
|
+
*
|
|
613
|
+
* Saying so matters: a client that reads an unqualified 401 as
|
|
614
|
+
* "your session is over" turns one pod restart into a sign-in
|
|
615
|
+
* prompt for somebody whose session has six more days on it.
|
|
616
|
+
* That is exactly what happened to the realtime bar.
|
|
617
617
|
*/ [SYNTHESIZED_AUTH_HEADER]: 'refresh-failed'
|
|
618
618
|
}
|
|
619
619
|
});
|
|
@@ -637,14 +637,14 @@ export function createHttpProxyHandler(config) {
|
|
|
637
637
|
} catch (error) {
|
|
638
638
|
const duration = performance.now() - startTime;
|
|
639
639
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
640
|
-
/*
|
|
641
|
-
* The person closed the tab. Nothing failed, and there is nobody left
|
|
642
|
-
* to send a status to — so this is not an error, and calling it one
|
|
643
|
-
* sends whoever reads the log hunting a timeout that never happened.
|
|
644
|
-
*
|
|
645
|
-
* Reported with the elapsed time, because on a streaming path "the
|
|
646
|
-
* client left after 4 minutes" is genuinely useful and "Gateway
|
|
647
|
-
* Timeout" is genuinely misleading.
|
|
640
|
+
/*
|
|
641
|
+
* The person closed the tab. Nothing failed, and there is nobody left
|
|
642
|
+
* to send a status to — so this is not an error, and calling it one
|
|
643
|
+
* sends whoever reads the log hunting a timeout that never happened.
|
|
644
|
+
*
|
|
645
|
+
* Reported with the elapsed time, because on a streaming path "the
|
|
646
|
+
* client left after 4 minutes" is genuinely useful and "Gateway
|
|
647
|
+
* Timeout" is genuinely misleading.
|
|
648
648
|
*/ if (err.name === 'ClientGoneError') {
|
|
649
649
|
logger.info(`Client disconnected after ${duration.toFixed(0)}ms, upstream cancelled: ${path}`);
|
|
650
650
|
return new Response(null, {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* How a client tells "the API refused you" from "we could not ask".
|
|
3
|
-
*
|
|
4
|
-
* A proxy that refreshes tokens on the way through has to answer something when
|
|
5
|
-
* the refresh itself fails, and 401 is the honest status — the request cannot
|
|
6
|
-
* be authorised. But the CAUSE is usually that the backend is unreachable, and
|
|
7
|
-
* a client that reads it as "this session is over" turns one pod restart into a
|
|
8
|
-
* sign-in prompt for somebody whose session has six more days on it.
|
|
9
|
-
*
|
|
10
|
-
* So the proxy stamps the 401s it invented. An unstamped 401 came from the API.
|
|
1
|
+
/**
|
|
2
|
+
* How a client tells "the API refused you" from "we could not ask".
|
|
3
|
+
*
|
|
4
|
+
* A proxy that refreshes tokens on the way through has to answer something when
|
|
5
|
+
* the refresh itself fails, and 401 is the honest status — the request cannot
|
|
6
|
+
* be authorised. But the CAUSE is usually that the backend is unreachable, and
|
|
7
|
+
* a client that reads it as "this session is over" turns one pod restart into a
|
|
8
|
+
* sign-in prompt for somebody whose session has six more days on it.
|
|
9
|
+
*
|
|
10
|
+
* So the proxy stamps the 401s it invented. An unstamped 401 came from the API.
|
|
11
11
|
*/ export const SYNTHESIZED_AUTH_HEADER = 'x-nucleus-auth-synthesized';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { deduplicatedRefresh } from './httpProxy';
|
|
2
2
|
import { addQueryParam, createProxyLogger, httpToWs, matchPath, parseCookies, rewritePath } from './utils';
|
|
3
|
-
/**
|
|
4
|
-
* True when an access token is absent or within `skewMs` of expiring. Decodes the JWT
|
|
5
|
-
* exp WITHOUT verifying (verification happens at the backend) — this only decides whether
|
|
6
|
-
* to pre-emptively refresh before the WS handshake.
|
|
3
|
+
/**
|
|
4
|
+
* True when an access token is absent or within `skewMs` of expiring. Decodes the JWT
|
|
5
|
+
* exp WITHOUT verifying (verification happens at the backend) — this only decides whether
|
|
6
|
+
* to pre-emptively refresh before the WS handshake.
|
|
7
7
|
*/ function accessTokenExpiringSoon(token, skewMs = 60000) {
|
|
8
8
|
if (!token) return true;
|
|
9
9
|
try {
|
|
@@ -16,20 +16,20 @@ import { addQueryParam, createProxyLogger, httpToWs, matchPath, parseCookies, re
|
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Keeps the proxy→backend socket alive with a PROTOCOL ping, not a data frame.
|
|
21
|
-
*
|
|
22
|
-
* This used to send `''`. An empty text frame is still application data, so it
|
|
23
|
-
* arrived at the backend's `message` handler, where `JSON.parse('')` throws —
|
|
24
|
-
* and nucleus' own PubSub route logged "Failed to parse client message" once per
|
|
25
|
-
* interval per connected client. On a busy IDP that is the entire debug log, and
|
|
26
|
-
* it names the proxy's own heartbeat as a misbehaving client.
|
|
27
|
-
*
|
|
28
|
-
* `ping()` is a control frame: it never reaches `message`, the peer answers with
|
|
29
|
-
* pong automatically, and it resets the idle timer the same way. Bun's WebSocket
|
|
30
|
-
* client implements it; the JSON heartbeat is the fallback for a runtime that
|
|
31
|
-
* does not, because `{"type":"ping"}` is a frame the PubSub route understands and
|
|
32
|
-
* replies to, rather than one it can only fail to parse.
|
|
19
|
+
/**
|
|
20
|
+
* Keeps the proxy→backend socket alive with a PROTOCOL ping, not a data frame.
|
|
21
|
+
*
|
|
22
|
+
* This used to send `''`. An empty text frame is still application data, so it
|
|
23
|
+
* arrived at the backend's `message` handler, where `JSON.parse('')` throws —
|
|
24
|
+
* and nucleus' own PubSub route logged "Failed to parse client message" once per
|
|
25
|
+
* interval per connected client. On a busy IDP that is the entire debug log, and
|
|
26
|
+
* it names the proxy's own heartbeat as a misbehaving client.
|
|
27
|
+
*
|
|
28
|
+
* `ping()` is a control frame: it never reaches `message`, the peer answers with
|
|
29
|
+
* pong automatically, and it resets the idle timer the same way. Bun's WebSocket
|
|
30
|
+
* client implements it; the JSON heartbeat is the fallback for a runtime that
|
|
31
|
+
* does not, because `{"type":"ping"}` is a frame the PubSub route understands and
|
|
32
|
+
* replies to, rather than one it can only fail to parse.
|
|
33
33
|
*/ function pingBackend(backendWs) {
|
|
34
34
|
const withPing = backendWs;
|
|
35
35
|
if (typeof withPing.ping === 'function') {
|
|
@@ -51,14 +51,14 @@ export function createWsProxyHandler(config) {
|
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
55
|
-
* The backend URL, plus whether this handshake carries a credential at all.
|
|
56
|
-
*
|
|
57
|
-
* The second half is the point. This function already KNEW when it had no
|
|
58
|
-
* token — it logged "No token found" and handed back a URL anyway, and the
|
|
59
|
-
* caller upgraded the socket regardless. That silence is what turns one
|
|
60
|
-
* logged-out browser tab into a permanent reconnect storm; see the refusal in
|
|
61
|
-
* `upgrade()` for the measurements.
|
|
54
|
+
/**
|
|
55
|
+
* The backend URL, plus whether this handshake carries a credential at all.
|
|
56
|
+
*
|
|
57
|
+
* The second half is the point. This function already KNEW when it had no
|
|
58
|
+
* token — it logged "No token found" and handed back a URL anyway, and the
|
|
59
|
+
* caller upgraded the socket regardless. That silence is what turns one
|
|
60
|
+
* logged-out browser tab into a permanent reconnect storm; see the refusal in
|
|
61
|
+
* `upgrade()` for the measurements.
|
|
62
62
|
*/ async function buildBackendUrl(path, target, cookies, query) {
|
|
63
63
|
let baseUrl = target.url.replace(/\/$/, '');
|
|
64
64
|
if (baseUrl.startsWith('http')) {
|
|
@@ -244,21 +244,21 @@ export function createWsProxyHandler(config) {
|
|
|
244
244
|
rejectUnauthorized: state.rejectUnauthorized
|
|
245
245
|
}
|
|
246
246
|
});
|
|
247
|
-
/*
|
|
248
|
-
* Ask for ArrayBuffer, because the default is not one.
|
|
249
|
-
*
|
|
250
|
-
* Bun's client WebSocket defaults `binaryType` to `'nodebuffer'`, so a
|
|
251
|
-
* binary frame arrives as a Node `Buffer`. A Buffer is neither an
|
|
252
|
-
* `ArrayBuffer` nor a `Blob`, so it matched none of the branches below
|
|
253
|
-
* and was silently discarded — a proxy that relays text perfectly and
|
|
254
|
-
* drops every binary frame without a word.
|
|
255
|
-
*
|
|
256
|
-
* Measured against a Next 16 dev server, which sends its React debug
|
|
257
|
-
* chunks as binary on the HMR socket: 5 frames direct, 2 of them
|
|
258
|
-
* binary; 3 frames through the proxy, 0 binary. The consequence was a
|
|
259
|
-
* page that never hydrated at all — the client awaits those chunks
|
|
260
|
-
* before calling `hydrateRoot`, so the whole app stayed as the server
|
|
261
|
-
* had sent it, blank.
|
|
247
|
+
/*
|
|
248
|
+
* Ask for ArrayBuffer, because the default is not one.
|
|
249
|
+
*
|
|
250
|
+
* Bun's client WebSocket defaults `binaryType` to `'nodebuffer'`, so a
|
|
251
|
+
* binary frame arrives as a Node `Buffer`. A Buffer is neither an
|
|
252
|
+
* `ArrayBuffer` nor a `Blob`, so it matched none of the branches below
|
|
253
|
+
* and was silently discarded — a proxy that relays text perfectly and
|
|
254
|
+
* drops every binary frame without a word.
|
|
255
|
+
*
|
|
256
|
+
* Measured against a Next 16 dev server, which sends its React debug
|
|
257
|
+
* chunks as binary on the HMR socket: 5 frames direct, 2 of them
|
|
258
|
+
* binary; 3 frames through the proxy, 0 binary. The consequence was a
|
|
259
|
+
* page that never hydrated at all — the client awaits those chunks
|
|
260
|
+
* before calling `hydrateRoot`, so the whole app stayed as the server
|
|
261
|
+
* had sent it, blank.
|
|
262
262
|
*/ backendWs.binaryType = 'arraybuffer';
|
|
263
263
|
backendWs.onopen = ()=>{
|
|
264
264
|
logger.info(`Backend connected: ${state.path}`);
|
|
@@ -16,13 +16,13 @@ function buildWsUrl(config) {
|
|
|
16
16
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
17
17
|
return `${protocol}//${window.location.host}${path}${query}`;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
* One WebSocket per distinct URL, shared across every `usePubSub` caller via
|
|
21
|
-
* ref-counting. Previously each hook instance opened its own socket, so two
|
|
22
|
-
* components subscribing for the same user (e.g. the header bell and the chat
|
|
23
|
-
* panel) created competing connections — the server de-dupes by user, dropping
|
|
24
|
-
* one and breaking realtime delivery (and the message chime). Sharing a single
|
|
25
|
-
* socket eliminates that and the duplicate events it produced.
|
|
19
|
+
/**
|
|
20
|
+
* One WebSocket per distinct URL, shared across every `usePubSub` caller via
|
|
21
|
+
* ref-counting. Previously each hook instance opened its own socket, so two
|
|
22
|
+
* components subscribing for the same user (e.g. the header bell and the chat
|
|
23
|
+
* panel) created competing connections — the server de-dupes by user, dropping
|
|
24
|
+
* one and breaking realtime delivery (and the message chime). Sharing a single
|
|
25
|
+
* socket eliminates that and the duplicate events it produced.
|
|
26
26
|
*/ const connections = new Map();
|
|
27
27
|
let eventIdCounter = 0;
|
|
28
28
|
function log(conn, ...args) {
|
|
@@ -64,19 +64,19 @@ function startHeartbeat(conn) {
|
|
|
64
64
|
}
|
|
65
65
|
}, conn.options.heartbeatInterval);
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
68
|
-
* How many refused handshakes in a row before the client stops trying.
|
|
69
|
-
*
|
|
70
|
-
* Small on purpose: a refused handshake is a session problem, and the second
|
|
71
|
-
* attempt tells you the first was not a fluke. Anything beyond that is a storm.
|
|
67
|
+
/**
|
|
68
|
+
* How many refused handshakes in a row before the client stops trying.
|
|
69
|
+
*
|
|
70
|
+
* Small on purpose: a refused handshake is a session problem, and the second
|
|
71
|
+
* attempt tells you the first was not a fluke. Anything beyond that is a storm.
|
|
72
72
|
*/ const HANDSHAKE_FAILURE_LIMIT = 3;
|
|
73
|
-
/**
|
|
74
|
-
* Find out whether the session is actually gone before saying so.
|
|
75
|
-
*
|
|
76
|
-
* A 401 or 403 is the server telling us in words. Anything else — a 200, a 500,
|
|
77
|
-
* a network failure — is not evidence about the session, so the connection goes
|
|
78
|
-
* back to reconnecting and the screen keeps saying "reconnecting", which is
|
|
79
|
-
* both true and not alarming.
|
|
73
|
+
/**
|
|
74
|
+
* Find out whether the session is actually gone before saying so.
|
|
75
|
+
*
|
|
76
|
+
* A 401 or 403 is the server telling us in words. Anything else — a 200, a 500,
|
|
77
|
+
* a network failure — is not evidence about the session, so the connection goes
|
|
78
|
+
* back to reconnecting and the screen keeps saying "reconnecting", which is
|
|
79
|
+
* both true and not alarming.
|
|
80
80
|
*/ async function confirmSessionThenDecide(conn) {
|
|
81
81
|
const path = conn.options.sessionProbePath;
|
|
82
82
|
if (!path) {
|
|
@@ -93,15 +93,15 @@ function startHeartbeat(conn) {
|
|
|
93
93
|
method: 'GET'
|
|
94
94
|
});
|
|
95
95
|
if (res.status === 401 || res.status === 403) {
|
|
96
|
-
/*
|
|
97
|
-
* Only if the API itself said so.
|
|
98
|
-
*
|
|
99
|
-
* A proxy that refreshes tokens on the way through answers 401 when the
|
|
100
|
-
* REFRESH fails, and refresh fails for the same reason the socket did —
|
|
101
|
-
* the API is restarting. Believing that 401 turns one pod roll into
|
|
102
|
-
* "oturumunuz artık geçerli değil" on a session that is perfectly alive,
|
|
103
|
-
* which is the original defect wearing a new hat. The proxy marks its
|
|
104
|
-
* own 401s; see NUCLEUS_SYNTHESIZED_AUTH_HEADER.
|
|
96
|
+
/*
|
|
97
|
+
* Only if the API itself said so.
|
|
98
|
+
*
|
|
99
|
+
* A proxy that refreshes tokens on the way through answers 401 when the
|
|
100
|
+
* REFRESH fails, and refresh fails for the same reason the socket did —
|
|
101
|
+
* the API is restarting. Believing that 401 turns one pod roll into
|
|
102
|
+
* "oturumunuz artık geçerli değil" on a session that is perfectly alive,
|
|
103
|
+
* which is the original defect wearing a new hat. The proxy marks its
|
|
104
|
+
* own 401s; see NUCLEUS_SYNTHESIZED_AUTH_HEADER.
|
|
105
105
|
*/ if (res.headers.get(SYNTHESIZED_AUTH_HEADER)) {
|
|
106
106
|
log(conn, 'Probe 401 came from the proxy, not the API; treating as a network problem');
|
|
107
107
|
} else {
|
|
@@ -124,10 +124,10 @@ function startHeartbeat(conn) {
|
|
|
124
124
|
conn.handshakeFailures = 0;
|
|
125
125
|
scheduleReconnect(conn);
|
|
126
126
|
}
|
|
127
|
-
/**
|
|
128
|
-
* Stop retrying on a clock; start again when something real changes.
|
|
129
|
-
*
|
|
130
|
-
* See `armReturnListeners` for why this is two listeners and not a timer.
|
|
127
|
+
/**
|
|
128
|
+
* Stop retrying on a clock; start again when something real changes.
|
|
129
|
+
*
|
|
130
|
+
* See `armReturnListeners` for why this is two listeners and not a timer.
|
|
131
131
|
*/ function waitForReturn(conn) {
|
|
132
132
|
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
133
133
|
// A connection nobody holds any more must not be woken; the probe that armed
|
|
@@ -151,15 +151,15 @@ function scheduleReconnect(conn) {
|
|
|
151
151
|
if (!conn.options.autoReconnect) return;
|
|
152
152
|
if (connections.get(conn.url) !== conn) return;
|
|
153
153
|
if (conn.reconnectAttempt >= conn.options.maxReconnectAttempts) {
|
|
154
|
-
/*
|
|
155
|
-
* Out of attempts — but "out of attempts" used to mean "dead until the
|
|
156
|
-
* person reloads the page". Ten tries with the backoff above is about three
|
|
157
|
-
* minutes, so a closed laptop, a lunch break or a deploy that takes a while
|
|
158
|
-
* left realtime permanently off while the page still looked alive.
|
|
159
|
-
*
|
|
160
|
-
* Waiting is right; giving up is not. So stop retrying on a timer (that is
|
|
161
|
-
* what caused the storm) and instead wait for an event that says the world
|
|
162
|
-
* changed: the network came back, or the person looked at the tab again.
|
|
154
|
+
/*
|
|
155
|
+
* Out of attempts — but "out of attempts" used to mean "dead until the
|
|
156
|
+
* person reloads the page". Ten tries with the backoff above is about three
|
|
157
|
+
* minutes, so a closed laptop, a lunch break or a deploy that takes a while
|
|
158
|
+
* left realtime permanently off while the page still looked alive.
|
|
159
|
+
*
|
|
160
|
+
* Waiting is right; giving up is not. So stop retrying on a timer (that is
|
|
161
|
+
* what caused the storm) and instead wait for an event that says the world
|
|
162
|
+
* changed: the network came back, or the person looked at the tab again.
|
|
163
163
|
*/ conn.dispatch.setConnectionStatus('disconnected');
|
|
164
164
|
conn.dispatch.setError(new Error('Max reconnection attempts reached'));
|
|
165
165
|
log(conn, 'Max reconnect attempts reached; waiting for network or tab to return');
|
|
@@ -242,21 +242,21 @@ function openSocket(conn) {
|
|
|
242
242
|
ws.onopen = ()=>{
|
|
243
243
|
log(conn, 'WebSocket opened');
|
|
244
244
|
opened = true;
|
|
245
|
-
/*
|
|
246
|
-
* The counters are NOT reset here, and that is the point.
|
|
247
|
-
*
|
|
248
|
-
* "The socket opened" proves nothing when there is a proxy in front: the
|
|
249
|
-
* proxy upgrades the browser's socket first and dials the backend after,
|
|
250
|
-
* so a handshake the backend refuses still reaches `onopen`, and the
|
|
251
|
-
* server's own refusal arrives afterwards as close code 4001. Resetting
|
|
252
|
-
* here meant every such cycle set `reconnectAttempt` back to 0 — the
|
|
253
|
-
* backoff never grew past its first step and the attempt cap was never
|
|
254
|
-
* reached. Simulated against this module's control flow: one hour, one
|
|
255
|
-
* tab, 3496 socket attempts at a flat ~1030 ms, forever.
|
|
256
|
-
*
|
|
257
|
-
* The honest signal is the server's `connected` message, which only
|
|
258
|
-
* arrives once it has actually registered the client. `handleMessage`
|
|
259
|
-
* resets both there.
|
|
245
|
+
/*
|
|
246
|
+
* The counters are NOT reset here, and that is the point.
|
|
247
|
+
*
|
|
248
|
+
* "The socket opened" proves nothing when there is a proxy in front: the
|
|
249
|
+
* proxy upgrades the browser's socket first and dials the backend after,
|
|
250
|
+
* so a handshake the backend refuses still reaches `onopen`, and the
|
|
251
|
+
* server's own refusal arrives afterwards as close code 4001. Resetting
|
|
252
|
+
* here meant every such cycle set `reconnectAttempt` back to 0 — the
|
|
253
|
+
* backoff never grew past its first step and the attempt cap was never
|
|
254
|
+
* reached. Simulated against this module's control flow: one hour, one
|
|
255
|
+
* tab, 3496 socket attempts at a flat ~1030 ms, forever.
|
|
256
|
+
*
|
|
257
|
+
* The honest signal is the server's `connected` message, which only
|
|
258
|
+
* arrives once it has actually registered the client. `handleMessage`
|
|
259
|
+
* resets both there.
|
|
260
260
|
*/ startHeartbeat(conn);
|
|
261
261
|
};
|
|
262
262
|
ws.onmessage = (event)=>handleMessage(conn, event.data);
|
|
@@ -273,21 +273,21 @@ function openSocket(conn) {
|
|
|
273
273
|
conn.dispatch.setConnectionStatus('disconnected');
|
|
274
274
|
conn.dispatch.setClientId(null);
|
|
275
275
|
if (connections.get(conn.url) !== conn) return;
|
|
276
|
-
/*
|
|
277
|
-
* 4001 = the backend refused the handshake's bearer. That is NOT the same
|
|
278
|
-
* as "the session is over", and treating it as such is why a live session
|
|
279
|
-
* stopped receiving messages.
|
|
280
|
-
*
|
|
281
|
-
* The handshake bearer is the SHORT-LIVED access token, while the session
|
|
282
|
-
* itself can be configured to never expire. An access token that aged out
|
|
283
|
-
* — because the tab sat idle, or the pre-handshake refresh at the proxy
|
|
284
|
-
* happened to fail once — produces exactly this 4001 while the person is
|
|
285
|
-
* still perfectly signed in.
|
|
286
|
-
*
|
|
287
|
-
* The probe is the right answer twice over: it tells us the truth, and it
|
|
288
|
-
* REPAIRS this case on the way, because the same proxy that serves it
|
|
289
|
-
* exchanges the refresh cookie for a fresh access token. So by the time the
|
|
290
|
-
* next handshake goes out, the bearer it carries is a new one.
|
|
276
|
+
/*
|
|
277
|
+
* 4001 = the backend refused the handshake's bearer. That is NOT the same
|
|
278
|
+
* as "the session is over", and treating it as such is why a live session
|
|
279
|
+
* stopped receiving messages.
|
|
280
|
+
*
|
|
281
|
+
* The handshake bearer is the SHORT-LIVED access token, while the session
|
|
282
|
+
* itself can be configured to never expire. An access token that aged out
|
|
283
|
+
* — because the tab sat idle, or the pre-handshake refresh at the proxy
|
|
284
|
+
* happened to fail once — produces exactly this 4001 while the person is
|
|
285
|
+
* still perfectly signed in.
|
|
286
|
+
*
|
|
287
|
+
* The probe is the right answer twice over: it tells us the truth, and it
|
|
288
|
+
* REPAIRS this case on the way, because the same proxy that serves it
|
|
289
|
+
* exchanges the refresh cookie for a fresh access token. So by the time the
|
|
290
|
+
* next handshake goes out, the bearer it carries is a new one.
|
|
291
291
|
*/ if (event.code === 4001) {
|
|
292
292
|
log(conn, 'Handshake bearer refused (4001); asking whether the session is still valid');
|
|
293
293
|
void confirmSessionThenDecide(conn);
|
|
@@ -296,25 +296,25 @@ function openSocket(conn) {
|
|
|
296
296
|
if (!opened) {
|
|
297
297
|
conn.handshakeFailures += 1;
|
|
298
298
|
if (conn.handshakeFailures >= HANDSHAKE_FAILURE_LIMIT) {
|
|
299
|
-
/*
|
|
300
|
-
* This used to declare the session over. It was wrong, and people saw
|
|
301
|
-
* it: `!opened` only says the socket never came up, and a socket fails
|
|
302
|
-
* to come up for a dozen reasons that have nothing to do with the
|
|
303
|
-
* session — the API pod restarting (which some installs do every few
|
|
304
|
-
* hours), a deploy, a laptop waking, a wifi hiccup. Three of those in a
|
|
305
|
-
* row and the screen told the person their session had expired. It had
|
|
306
|
-
* not, and on an install running sessions that never expire the
|
|
307
|
-
* sentence was not even possible.
|
|
308
|
-
*
|
|
309
|
-
* The close code cannot settle it either: a handshake refused before
|
|
310
|
-
* the upgrade reaches the browser as 1006, exactly like a network drop.
|
|
311
|
-
*
|
|
312
|
-
* So: ASK, then say. One cheap cookie-authenticated request answers the
|
|
313
|
-
* only question that matters, and until it answers, nobody is told
|
|
314
|
-
* anything about their session.
|
|
315
|
-
*
|
|
316
|
-
* Server load was never the reason for this branch anyway — the backoff
|
|
317
|
-
* and the attempt cap above already bound the retries.
|
|
299
|
+
/*
|
|
300
|
+
* This used to declare the session over. It was wrong, and people saw
|
|
301
|
+
* it: `!opened` only says the socket never came up, and a socket fails
|
|
302
|
+
* to come up for a dozen reasons that have nothing to do with the
|
|
303
|
+
* session — the API pod restarting (which some installs do every few
|
|
304
|
+
* hours), a deploy, a laptop waking, a wifi hiccup. Three of those in a
|
|
305
|
+
* row and the screen told the person their session had expired. It had
|
|
306
|
+
* not, and on an install running sessions that never expire the
|
|
307
|
+
* sentence was not even possible.
|
|
308
|
+
*
|
|
309
|
+
* The close code cannot settle it either: a handshake refused before
|
|
310
|
+
* the upgrade reaches the browser as 1006, exactly like a network drop.
|
|
311
|
+
*
|
|
312
|
+
* So: ASK, then say. One cheap cookie-authenticated request answers the
|
|
313
|
+
* only question that matters, and until it answers, nobody is told
|
|
314
|
+
* anything about their session.
|
|
315
|
+
*
|
|
316
|
+
* Server load was never the reason for this branch anyway — the backoff
|
|
317
|
+
* and the attempt cap above already bound the retries.
|
|
318
318
|
*/ void confirmSessionThenDecide(conn);
|
|
319
319
|
return;
|
|
320
320
|
}
|
|
@@ -393,13 +393,13 @@ export function usePubSub(config) {
|
|
|
393
393
|
return {
|
|
394
394
|
isConnected: store.connection.status === 'connected',
|
|
395
395
|
isConnecting: store.connection.status === 'connecting' || store.connection.status === 'reconnecting',
|
|
396
|
-
/**
|
|
397
|
-
* The session is over and realtime will not return on its own.
|
|
398
|
-
*
|
|
399
|
-
* Exposed as its own flag so a screen can say so. Without it the page keeps
|
|
400
|
-
* its live appearance and quietly delivers nothing — which is worse than
|
|
401
|
-
* the reconnect storm this replaced, because at least the storm was visible
|
|
402
|
-
* to somebody.
|
|
396
|
+
/**
|
|
397
|
+
* The session is over and realtime will not return on its own.
|
|
398
|
+
*
|
|
399
|
+
* Exposed as its own flag so a screen can say so. Without it the page keeps
|
|
400
|
+
* its live appearance and quietly delivers nothing — which is worse than
|
|
401
|
+
* the reconnect storm this replaced, because at least the storm was visible
|
|
402
|
+
* to somebody.
|
|
403
403
|
*/ sessionExpired: store.connection.status === 'unauthorized',
|
|
404
404
|
clientId: store.connection.clientId,
|
|
405
405
|
subscribedTopics: store.connection.subscribedTopics,
|