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.
@@ -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,