akanjs 3.1.0-rc.26 → 3.1.0-rc.27

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.
Files changed (70) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/client/href.ts +8 -0
  4. package/client/router.ts +40 -2
  5. package/common/Logger.ts +6 -4
  6. package/fetch/client/httpClient.ts +38 -10
  7. package/package.json +2 -2
  8. package/server/akanApp.ts +2 -2
  9. package/server/akanServer.ts +10 -5
  10. package/server/mcp/McpDispatcher.ts +2 -1
  11. package/server/resolver/signal.resolver.ts +41 -31
  12. package/server/routeState.ts +1 -0
  13. package/server/rscClient.tsx +47 -22
  14. package/server/rscClientFetch.ts +8 -3
  15. package/server/rscNavigationState.ts +2 -2
  16. package/server/rscScrollRestoration.ts +21 -8
  17. package/server/rscWorker.tsx +34 -9
  18. package/server/webRouter.ts +2 -0
  19. package/service/predefinedAdaptor/database.adaptor.ts +27 -1
  20. package/service/predefinedAdaptor/solidQueue.adaptor.ts +30 -9
  21. package/service/predefinedAdaptor/sql/QueryCompiler.ts +13 -7
  22. package/service/predefinedAdaptor/sql/SqlDocumentStore.ts +65 -31
  23. package/service/predefinedAdaptor/sql/dialect/postgres.ts +4 -2
  24. package/service/predefinedAdaptor/sql/dialect/sqlite.ts +5 -1
  25. package/service/predefinedAdaptor/sql/types.ts +11 -1
  26. package/signal/TaggedJson.ts +26 -4
  27. package/signal/signalContext.ts +0 -8
  28. package/store/action.ts +82 -34
  29. package/store/liveInsert.ts +5 -1
  30. package/store/sliceKeys.ts +1 -0
  31. package/store/state.ts +6 -2
  32. package/store/storeInstance.ts +12 -3
  33. package/types/client/href.d.ts +3 -0
  34. package/types/client/router.d.ts +6 -1
  35. package/types/server/akanServer.d.ts +10 -5
  36. package/types/server/routeState.d.ts +1 -0
  37. package/types/server/rscClientFetch.d.ts +2 -0
  38. package/types/server/rscNavigationState.d.ts +1 -1
  39. package/types/server/rscScrollRestoration.d.ts +2 -0
  40. package/types/service/predefinedAdaptor/database.adaptor.d.ts +2 -1
  41. package/types/service/predefinedAdaptor/solidQueue.adaptor.d.ts +1 -0
  42. package/types/service/predefinedAdaptor/sql/dialect/sqlite.d.ts +1 -0
  43. package/types/service/predefinedAdaptor/sql/types.d.ts +11 -1
  44. package/types/signal/signalContext.d.ts +0 -2
  45. package/types/store/liveInsert.d.ts +3 -1
  46. package/types/store/sliceKeys.d.ts +1 -0
  47. package/types/store/state.d.ts +3 -3
  48. package/types/ui/Load/sliceClaim.d.ts +28 -0
  49. package/types/ui/Model/Edit.d.ts +3 -2
  50. package/types/ui/sliceNamesOf.d.ts +2 -0
  51. package/types/vendor/@akanjs/native/packages/cli/src/lib/compat.d.ts +6 -0
  52. package/types/webkit/CsrStack.d.ts +3 -2
  53. package/ui/Dialog/LegacyModal.tsx +7 -5
  54. package/ui/Dialog/Modal.tsx +7 -4
  55. package/ui/Field/Collection.tsx +2 -1
  56. package/ui/Load/Units.tsx +18 -6
  57. package/ui/Load/sliceClaim.ts +164 -0
  58. package/ui/Model/Edit.tsx +22 -3
  59. package/ui/Model/EditModal.tsx +5 -1
  60. package/ui/Model/LoadStore.tsx +1 -1
  61. package/ui/Model/NewWrapper_Client.tsx +1 -1
  62. package/ui/System/Client.tsx +3 -0
  63. package/vendor/@akanjs/native/packages/cli/src/lib/compat.ts +6 -2
  64. package/vendor/@akanjs/native/packages/cli/src/lib/updates.ts +3 -3
  65. package/vendor/@akanjs/native/plugins/updates/src/desktop.ts +45 -15
  66. package/webkit/CsrStack.ts +21 -3
  67. package/webkit/McpAppAgent.ts +4 -2
  68. package/webkit/McpAppFetchBridge.ts +2 -1
  69. package/webkit/useCsrValues.ts +11 -0
  70. package/webkit/useEscapeKey.tsx +1 -1
package/README.ko.md CHANGED
@@ -22,7 +22,7 @@
22
22
  ```text
23
23
  Akan.js(https://akanjs.com) 워크스페이스를 새로 만들어 줘.
24
24
 
25
- 1. Bun 1.4 이상이 설치돼 있는지 확인해 줘(`bun --version`). 없거나 버전이 낮으면 https://bun.sh 안내대로 설치하거나 업그레이드해 줘.
25
+ 1. Bun 1.4.4 이상이 설치돼 있는지 확인해 줘(`bun --version`). 없거나 버전이 낮으면 https://bun.sh 안내대로 설치하거나 업그레이드해 줘.
26
26
  2. 워크스페이스 이름과 첫 앱 이름을 물어봐 줘. 짧은 영문 소문자로, 아래에 적어 뒀다면 그걸 쓰면 돼.
27
27
  3. 이 디렉터리에서 `bunx create-akan-workspace@latest <워크스페이스> --app <앱>`을 실행해 줘. akan CLI를 전역으로 설치하고, ./<워크스페이스>를 만들어 의존성까지 설치해.
28
28
  4. ./<워크스페이스> 안에서 `akan start <앱>`으로 개발 서버를 백그라운드에 띄우고, 출력된 주소(기본 http://localhost:8282)가 열리는지 확인해 줘.
package/README.md CHANGED
@@ -22,7 +22,7 @@ or let the agent ask.
22
22
  ```text
23
23
  Set up a new Akan.js workspace for me (https://akanjs.com).
24
24
 
25
- 1. Check that Bun 1.4 or newer is installed (`bun --version`). If it isn't, install or upgrade it as https://bun.sh describes.
25
+ 1. Check that Bun 1.4.4 or newer is installed (`bun --version`). If it isn't, install or upgrade it as https://bun.sh describes.
26
26
  2. Ask me for a workspace name and a first app name, short and lowercase, unless I wrote them below.
27
27
  3. In this directory, run `bunx create-akan-workspace@latest <workspace> --app <app>`. It installs the akan CLI globally, creates ./<workspace> and installs its dependencies.
28
28
  4. Inside ./<workspace>, start the dev server in the background with `akan start <app>` and check that the URL it prints (http://localhost:8282 by default) loads.
package/client/href.ts CHANGED
@@ -25,6 +25,14 @@ export class Href {
25
25
  return /^https?:/i.test(href);
26
26
  }
27
27
 
28
+ /** Whether an http(s) href resolves, against `base`, to another origin than `base`'s. Any other href stays a path in
29
+ * the app; no base, as outside a request, leaves for anywhere. */
30
+ static leavesOrigin(href: string, base: string | undefined): boolean {
31
+ if (!Href.hasWebScheme(href)) return false;
32
+ const origin = base === undefined ? undefined : Href.#parse(base)?.origin;
33
+ return !origin || Href.#parse(href, base)?.origin !== origin;
34
+ }
35
+
28
36
  /** Canonical absolute URL for an external href: follows the browser on an http(s) document base; on a native
29
37
  * `app://` base an absolute URL resolves as written and a network path resolves against https:. */
30
38
  static toExternalUrl(href: string, base: string): string {
package/client/router.ts CHANGED
@@ -12,6 +12,9 @@ export interface RouteOptions {
12
12
  * navigation.
13
13
  */
14
14
  shallow?: boolean;
15
+ /** Lets an absolute URL on another origin through; without it the router refuses one, so a `?redirect=` handed on
16
+ * unchecked cannot send a visitor off-site. */
17
+ external?: boolean;
15
18
  }
16
19
  export interface RouteManifest {
17
20
  routes: string[];
@@ -55,6 +58,8 @@ export type RedirectStatus = 303 | 307 | 308;
55
58
  export interface RedirectOptions {
56
59
  method?: RedirectMethod;
57
60
  status?: RedirectStatus;
61
+ /** As `RouteOptions.external`; a target it refuses redirects to the index path instead. */
62
+ external?: boolean;
58
63
  }
59
64
 
60
65
  export class AkanRedirectError extends Error {
@@ -85,6 +90,16 @@ function getServerRequestContext() {
85
90
  };
86
91
  }
87
92
 
93
+ const getServerPublicOrigin = () => {
94
+ const { getRequest, headers } = getServerRequestContext();
95
+ const h = headers();
96
+ const url = getRequest()?.url;
97
+ const host = h.get("x-forwarded-host")?.split(",")[0]?.trim() ?? h.get("host");
98
+ const proto = h.get("x-forwarded-proto")?.split(",")[0]?.trim();
99
+ if (!host) return url;
100
+ return `${proto ?? (url ? new URL(url).protocol.replace(":", "") : "https")}://${host}`;
101
+ };
102
+
88
103
  const getConfiguredBasePaths = () => new Set(parseBasePaths(process.env.AKAN_PUBLIC_BASE_PATHS));
89
104
 
90
105
  const shouldExposeBasePath = () => getEnv().operationMode === "local";
@@ -306,7 +321,7 @@ class Router {
306
321
  }
307
322
  #withCsrRuntimeSearchParams(href: string) {
308
323
  const currentSearch = new URLSearchParams(csrLocation.search);
309
- if (currentSearch.get("csr") !== "true") return href;
324
+ if (currentSearch.get("csr") !== "true" || Href.hasWebScheme(href)) return href;
310
325
 
311
326
  const [hrefWithoutHash, hash = ""] = href.split("#");
312
327
  const [pathname, search = ""] = hrefWithoutHash.split("?");
@@ -391,6 +406,7 @@ class Router {
391
406
  }
392
407
  push(href: string, routeOptions?: RouteOptions) {
393
408
  this.#checkInitialized();
409
+ if (this.#refusesOffsite("push", href, routeOptions)) return undefined as never;
394
410
  this.#navigations += 1;
395
411
  this.#track(this.#instance.push(href, routeOptions));
396
412
  this.#rememberHistoryState("push");
@@ -399,6 +415,7 @@ class Router {
399
415
  }
400
416
  replace(href: string, routeOptions?: RouteOptions) {
401
417
  this.#checkInitialized();
418
+ if (this.#refusesOffsite("replace", href, routeOptions)) return undefined as never;
402
419
  this.#navigations += 1;
403
420
  this.#track(this.#instance.replace(href, routeOptions));
404
421
  this.#rememberHistoryState("replace");
@@ -410,6 +427,20 @@ class Router {
410
427
  navigation(): Promise<void> {
411
428
  return this.#navigation;
412
429
  }
430
+ #leavesOrigin(href: string, external?: boolean) {
431
+ if (external || !Href.hasWebScheme(href)) return false;
432
+ return Href.leavesOrigin(
433
+ href,
434
+ getEnv().side === "server" ? getServerPublicOrigin() : globalThis.window?.location?.href,
435
+ );
436
+ }
437
+ #refusesOffsite(method: "push" | "replace", href: string, routeOptions?: RouteOptions) {
438
+ if (!this.#leavesOrigin(href, routeOptions?.external)) return false;
439
+ const message = `router.${method}(${href}) leaves this origin and was refused; pass { external: true } to leave on purpose`;
440
+ Logger.warn(message);
441
+ this.#track(Promise.reject(new Error(message)));
442
+ return true;
443
+ }
413
444
  #track(result: void | Promise<void>) {
414
445
  this.#navigation = Promise.resolve(result);
415
446
 
@@ -456,9 +487,16 @@ class Router {
456
487
  navigationCount(): number {
457
488
  return this.#navigations;
458
489
  }
459
- redirect(href: string, options: RedirectOptions = {}): never {
490
+ redirect(target: string, options: RedirectOptions = {}): never {
460
491
  const method = options.method ?? "replace";
461
492
  const status = options.status ?? 307;
493
+
494
+ const offsite = this.#leavesOrigin(target, options.external);
495
+ if (offsite)
496
+ Logger.warn(
497
+ `router.redirect(${target}) leaves this origin, so it redirects to ${this.#indexPath} instead; pass { external: true } to leave on purpose`,
498
+ );
499
+ const href = offsite ? this.#indexPath : target;
462
500
  if (getEnv().side === "server") {
463
501
  const { getRequest, headers: requestHeaders } = getServerRequestContext();
464
502
  const h = requestHeaders();
package/common/Logger.ts CHANGED
@@ -89,7 +89,9 @@ const consoleMethodMap = {
89
89
  const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
90
90
 
91
91
  const secretKey =
92
- /password|passwd|passphrase|^pass$|pwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key|access[-_]?key|auth[-_]?key|hash[-_]?key|(?:signing|encryption|master|session)[-_]?key|webhook|cred|dsn$|connection[-_]?string|iap[-_]?verify|service[-_]?account/i;
92
+ /password|passwd|passphrase|^pass$|pwd|token|jwt|authorization|cookie|secret|api[-_]?key|private[-_]?key|access[-_]?key|auth[-_]?key|hash[-_]?key|(?:signing|encryption|master|session)[-_]?key|webhook|credential|dsn$|connection[-_]?string|iap[-_]?verify|service[-_]?account/i;
93
+
94
+ const credWord = /[cC]reds?(?![a-z])|CREDS?(?![A-Z])/;
93
95
 
94
96
  const envValue = (key: string): string | undefined => (typeof process === "undefined" ? undefined : process.env?.[key]);
95
97
 
@@ -174,19 +176,19 @@ export class Logger {
174
176
  static inlineData(value: unknown, max = 500): string {
175
177
  let text: string;
176
178
  try {
177
- text = JSON.stringify(value, (key, val) => (key && secretKey.test(key) ? "[redacted]" : val)) ?? "";
179
+ text = JSON.stringify(value, (key, val) => (key && Logger.isSecretKey(key) ? "[redacted]" : val)) ?? "";
178
180
  } catch {
179
181
  text = "[unserializable]";
180
182
  }
181
183
  return text.length > max ? `${text.slice(0, max)}…` : text;
182
184
  }
183
185
  static isSecretKey(name: string) {
184
- return secretKey.test(name);
186
+ return secretKey.test(name) || credWord.test(name);
185
187
  }
186
188
  static redactAttrs(attrs: LogAttrs): LogAttrs {
187
189
  let redacted: LogAttrs | null = null;
188
190
  for (const key of Object.keys(attrs)) {
189
- if (!secretKey.test(key)) continue;
191
+ if (!Logger.isSecretKey(key)) continue;
190
192
  redacted ??= { ...attrs };
191
193
  redacted[key] = "[redacted]";
192
194
  }
@@ -1,6 +1,11 @@
1
- import { Backoff, isRecord, type RetryPolicy } from "akanjs/common";
1
+ import { Backoff, isRecord, Logger, type RetryPolicy } from "akanjs/common";
2
2
  import type { HttpMutationMethod, SerializedArg } from "akanjs/signal";
3
- import { type ErrorConstructor, type RestoredError, restoreRemoteError } from "./remoteError";
3
+ import {
4
+ type ErrorConstructor,
5
+ type ErrorResponsePayload,
6
+ type RestoredError,
7
+ restoreRemoteError,
8
+ } from "./remoteError";
4
9
 
5
10
  export interface HttpClientOptions {
6
11
  headers?: Record<string, string>;
@@ -42,6 +47,7 @@ const transportDetailLimit = 200;
42
47
  const DEFAULT_TIMEOUT_MS = 30_000;
43
48
 
44
49
  export class HttpClient {
50
+ static readonly #logger = new Logger("HttpClient");
45
51
  readonly baseUrl: string;
46
52
  #headers: Record<string, string>;
47
53
  #timeout?: number | false;
@@ -166,11 +172,11 @@ export class HttpClient {
166
172
 
167
173
  async #requestOnce<Returns>(url: string, init: RequestInit, deadline: number): Promise<Returns> {
168
174
  const res = await this.#fetch(url, this.#withDeadline(init, deadline));
169
- const body = await this.#readBody(res);
175
+ const body = await this.#readBody(url, res);
170
176
  if (res.ok) return body as Returns;
171
177
 
172
178
  if (res.status in transportErrorKeyMap && !HttpClient.#namesErrorKey(body))
173
- throw this.#transportError(res, JSON.stringify(body));
179
+ throw this.#transportError(url, res, JSON.stringify(body));
174
180
  throw this.#restoreError(body, res.status);
175
181
  }
176
182
 
@@ -197,21 +203,21 @@ export class HttpClient {
197
203
  if (error instanceof Error && error.name === "TimeoutError")
198
204
  throw this.#restoreError({ error: transportErrorKeyMap[408], data: { status: 408 } }, 408);
199
205
  if (error instanceof Error && error.name === "AbortError") throw error;
200
- throw this.#restoreError({ error: serverUnreachableKey, details: String(error) }, 503);
206
+ throw this.#transportFailure(url, { error: serverUnreachableKey, details: String(error) }, 503);
201
207
  }
202
208
  }
203
209
 
204
- async #readBody(res: Response) {
210
+ async #readBody(url: string, res: Response) {
205
211
  if (jsonContentType.test(res.headers.get("content-type") ?? "")) {
206
212
  try {
207
213
  return (await res.json()) as unknown;
208
214
  } catch (error) {
209
- throw this.#transportError(res, String(error));
215
+ throw this.#transportError(url, res, String(error));
210
216
  }
211
217
  }
212
218
  const raw = await res.text();
213
219
  const parsed = HttpClient.#parseJson(raw);
214
- if (parsed === undefined) throw this.#transportError(res, raw);
220
+ if (parsed === undefined) throw this.#transportError(url, res, raw);
215
221
  return parsed;
216
222
  }
217
223
 
@@ -223,11 +229,33 @@ export class HttpClient {
223
229
  }
224
230
  }
225
231
 
226
- #transportError(res: Response, detail: string): RestoredError {
232
+ #transportError(url: string, res: Response, detail: string): RestoredError {
227
233
  const { status } = res;
228
234
  const error = transportErrorKeyMap[status as keyof typeof transportErrorKeyMap] ?? unexpectedResponseKey;
229
235
  const data = status === 429 ? { status, seconds: HttpClient.#retryAfterSeconds(res) } : { status };
230
- return this.#restoreError({ error, data, details: detail.slice(0, transportDetailLimit) }, status);
236
+ return this.#transportFailure(url, { error, data, details: detail.slice(0, transportDetailLimit) }, status);
237
+ }
238
+
239
+ #transportFailure(url: string, body: ErrorResponsePayload, status: number): RestoredError {
240
+ if (typeof window === "undefined")
241
+ HttpClient.#logger.warn(`${body.error} from ${HttpClient.#originAndPath(url)}: ${JSON.stringify(body.details)}`);
242
+ const error = this.#restoreError(body, status);
243
+ const toJSON = error.toJSON?.bind(error);
244
+ if (toJSON)
245
+ error.toJSON = () => {
246
+ const { details: _local, ...wire } = toJSON();
247
+ return wire;
248
+ };
249
+ return error;
250
+ }
251
+
252
+ static #originAndPath(url: string) {
253
+ try {
254
+ const { origin, pathname } = new URL(url);
255
+ return `${origin}${pathname}`;
256
+ } catch {
257
+ return url.split("?")[0];
258
+ }
231
259
  }
232
260
 
233
261
  static #retryAfterSeconds(res: Response): number {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.0-rc.26",
3
+ "version": "3.1.0-rc.27",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -262,7 +262,7 @@
262
262
  "react-dom": "19.3.1",
263
263
  "react-icons": "^5.7.0",
264
264
  "react-refresh": "^0.19.0",
265
- "react-server-dom-webpack": "^19.3.1",
265
+ "react-server-dom-webpack": "19.3.1",
266
266
  "react-spring": "^10.0.4",
267
267
  "scheduler": "^0.28.0",
268
268
  "tailwind-scrollbar": "^4.0.2",
package/server/akanApp.ts CHANGED
@@ -109,7 +109,7 @@ export class AkanApp {
109
109
  readonly #devHosted = process.env.AKAN_COMMAND_TYPE === "start";
110
110
  readonly #healthTimeoutMs = AkanApp.#parseHealthTimeoutMs();
111
111
  readonly #upstreamWaitMs = AkanApp.#parseUpstreamWaitMs();
112
- /** Child stderr is bundler/runtime noise the gateway cannot act on; it still reaches the rotating log file. */
112
+ /** Child stderr is mostly bundler/runtime noise, kept to the log file; with none it may be a crash's only trace. */
113
113
  readonly #printChildStderr = process.env.AKAN_CHILD_STDERR === "1";
114
114
  readonly #serverPath: string;
115
115
  readonly #artifactDir: string;
@@ -1337,7 +1337,7 @@ export class AkanApp {
1337
1337
  return;
1338
1338
  }
1339
1339
  const prefixedLine = `[child:${idx} ${role}] [${type}] ${line}`;
1340
- if (type === "stdout" || this.#printChildStderr) process[type].write(prefixedLine);
1340
+ if (type === "stdout" || this.#printChildStderr || !this.#logWriter) process[type].write(prefixedLine);
1341
1341
  this.#logWriter?.write(`${idx}-${role}`, AkanApp.#stripAnsi(prefixedLine));
1342
1342
  }
1343
1343
 
@@ -66,7 +66,10 @@ export interface AkanServerProps extends AkanLibProps {
66
66
 
67
67
  export interface AkanServerOptions {
68
68
  openapi?: boolean;
69
- /** `/mcp` is mounted by default; `false` takes it off, and the object form carries the rest of its settings. */
69
+ /**
70
+ * `/mcp` is mounted by default, on an edge server only when asked for here or by `AKAN_MCP=true`; `false` takes it
71
+ * off, and the object form carries the rest of its settings.
72
+ */
70
73
  mcp?: boolean | McpServerOption;
71
74
  /** Boot only these modules and what they reach; omitted or empty mounts every enabled module. */
72
75
  modules?: string[];
@@ -107,13 +110,15 @@ export interface McpServerOption {
107
110
  /** Characters a page prompt may attach before its lists are cut; 60,000 by default. `AKAN_MCP_PROMPT_BUDGET`. */
108
111
  promptBudget?: number;
109
112
  /**
110
- * MCP Apps: a `.prompt()` page is a tool as well, whose result a host speaking the extension renders from the CSR
111
- * shell; an endpoint kept off the shelf by `mcp: false` stays callable by that view. `true` by default. `AKAN_MCP_APPS`.
113
+ * MCP Apps: each `.app()` is a read-only tool whose result a host speaking the extension draws with that app's own
114
+ * view, and a `.prompt()` page a text tool; an endpoint kept off the shelf by `mcp: false` stays callable by a view.
115
+ * `false` publishes no page tools. `true` by default. `AKAN_MCP_APPS`.
112
116
  */
113
117
  apps?: boolean;
114
118
  /**
115
- * Speak the modern `2026-07-28` revision (`server/discover`) beside the legacy ones. On unless MCP Apps renders views,
116
- * since claude.ai renders a view only for a server it reached through `initialize`. `AKAN_MCP_MODERN`.
119
+ * Speak the modern `2026-07-28` revision (`server/discover`) beside the legacy ones. On unless the build serves views
120
+ * and a page declares `.app()`, since claude.ai renders a view only for a server it reached through `initialize`.
121
+ * `AKAN_MCP_MODERN`.
117
122
  */
118
123
  modern?: boolean;
119
124
  }
@@ -72,7 +72,8 @@ export class McpDispatcher {
72
72
  }
73
73
 
74
74
  static #restorable(error: unknown, status: number | undefined) {
75
- if (!status || status >= 500 || status === 401 || status === 403) return null;
75
+ if (!status || status >= 500) return null;
76
+ if (status === 401 || status === 403) return { statusCode: status };
76
77
  return isExceptionLike(error) ? error.toJSON() : null;
77
78
  }
78
79
 
@@ -448,6 +448,8 @@ export class SignalResolver {
448
448
  ._addInternalArgs(sliceInfo.internalArgs)
449
449
  .exec(async function (this: any, ...requestArgs: any) {
450
450
  const args = requestArgs.slice(0, argLength);
451
+
452
+ SignalResolver.#assertNotPaused(liveKey, sliceInfo, args);
451
453
  const internalArgs = requestArgs.slice(argLength);
452
454
  return assertSliceQuery(
453
455
  await sliceInfo.execFn?.apply(this, [...args, ...internalArgs, documentQueryHelper]),
@@ -542,15 +544,10 @@ export class SignalResolver {
542
544
  }
543
545
  }
544
546
  }
545
- static #assertNotPaused(
546
- key: string,
547
- liveOption: LiveEndpointOption,
548
- endpointInfo: EndpointInfo,
549
- context: SignalContext,
550
- ) {
551
- for (const name of liveOption.pauseOn) {
552
- const idx = endpointInfo.args.findIndex((arg) => arg.name === name);
553
- if (idx < 0 || context.args[idx] == null) continue;
547
+ static #assertNotPaused(key: string, sliceInfo: SliceInfo, args: unknown[]) {
548
+ for (const name of sliceInfo.liveOption?.pauseOn ?? []) {
549
+ const idx = sliceInfo.args.findIndex((arg) => arg.name === name);
550
+ if (idx < 0 || args[idx] == null) continue;
554
551
  throw new Error(
555
552
  `Live room "${key}" is paused while "${name}" carries a value: the slice declared it in ` +
556
553
  `.live({ pauseOn }), so this window updates by refetching instead of subscribing.`,
@@ -696,11 +693,10 @@ export class SignalResolver {
696
693
  ).init();
697
694
  const liveOption = endpointInfo.signalOption.live;
698
695
 
696
+ const query = subscribe ? await context.exec() : null;
697
+
699
698
  const requestRoomId = context.getRoomId(key);
700
699
  if (subscribe) {
701
-
702
- if (liveOption) SignalResolver.#assertNotPaused(key, liveOption, endpointInfo, context);
703
- const query = await context.exec();
704
700
  const isClosed = SignalResolver.#closedWs.has(ws);
705
701
 
706
702
  if (isClosed || SignalResolver.#pendingSubscribes.isLeft(ws, requestRoomId, arrival)) {
@@ -752,19 +748,19 @@ export class SignalResolver {
752
748
  return ack;
753
749
  }
754
750
  SignalResolver.#pendingSubscribes.leave(ws, requestRoomId, arrival);
755
- if (liveOption) await context.resolveInternalArgs();
756
- const roomId = liveOption ? context.getLiveRoomId(key) : requestRoomId;
757
- ws.unsubscribe(roomId);
758
- const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
759
- const roomCtx = roomCtxMap?.get(roomId);
760
- if (roomCtxMap && roomCtx) {
761
- await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
762
- roomCtxMap.delete(roomId);
763
- if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
764
- SignalResolver.#leaveRoom(roomId, liveOption ? live : undefined);
765
- websocket.leaveRoom(ws, roomId);
751
+
752
+ const heldRooms = [...(SignalResolver.#liveWsPubsubRoomCtx.get(ws) ?? [])].filter(
753
+ ([, roomCtx]) => roomCtx.getRoomId(roomCtx.key) === requestRoomId,
754
+ );
755
+ for (const [roomId, roomCtx] of heldRooms) {
756
+ SignalResolver.#leaveWsRoom(ws, roomId, roomCtx, websocket, liveOption ? live : undefined);
766
757
  SignalResolver.logger.verbose(`WebSocket unsubscribed from room ${roomId}`);
767
758
  }
759
+ await SignalResolver.#runLifecycleHandlers(
760
+ heldRooms.map(([, roomCtx]) => roomCtx),
761
+ ["unsubscribe"],
762
+ );
763
+ const roomId = heldRooms[0]?.[0] ?? requestRoomId;
768
764
  const ack: WebsocketSubscribeAck = { type: "sub", roomId, requestRoomId, subscribe };
769
765
  return ack;
770
766
  });
@@ -850,17 +846,30 @@ export class SignalResolver {
850
846
  const revokedRooms: string[] = [];
851
847
  for (const [roomId, roomCtx] of [...roomCtxMap]) {
852
848
  if (await roomCtx.authorize()) continue;
853
- ws.unsubscribe(roomId);
854
- await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
855
- roomCtxMap.delete(roomId);
856
- SignalResolver.#leaveRoom(roomId, live);
857
- websocket.leaveRoom(ws, roomId);
849
+ if (!SignalResolver.#leaveWsRoom(ws, roomId, roomCtx, websocket, live)) continue;
858
850
  revokedRooms.push(roomId);
859
851
  SignalResolver.logger.verbose(`WebSocket lost access to room ${roomId}; unsubscribed`);
852
+ await SignalResolver.#runLifecycleHandlers([roomCtx], ["unsubscribe"]);
860
853
  }
861
- if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
862
854
  return revokedRooms;
863
855
  }
856
+
857
+ static #leaveWsRoom(
858
+ ws: Bun.ServerWebSocket<unknown>,
859
+ roomId: string,
860
+ roomCtx: SignalContext<WebSocketExecutionContext>,
861
+ websocket: WebsocketAdaptor,
862
+ live?: LiveRegistry,
863
+ ) {
864
+ const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
865
+ if (!roomCtxMap || roomCtxMap.get(roomId) !== roomCtx) return false;
866
+ roomCtxMap.delete(roomId);
867
+ if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
868
+ ws.unsubscribe(roomId);
869
+ SignalResolver.#leaveRoom(roomId, live);
870
+ websocket.leaveRoom(ws, roomId);
871
+ return true;
872
+ }
864
873
 
865
874
  static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
866
875
  await SignalResolver.#getWebsocket(registry).registerSocket(ws);
@@ -870,11 +879,12 @@ export class SignalResolver {
870
879
  SignalResolver.#closedWs.add(ws);
871
880
  const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
872
881
  const contexts = [...(roomCtxMap?.values() ?? []), ...(SignalResolver.#liveWsMessageCtx.get(ws) ?? [])];
873
- await SignalResolver.#runLifecycleHandlers(contexts, ["unsubscribe", "disconnect"]);
874
882
 
875
- for (const roomId of roomCtxMap?.keys() ?? []) SignalResolver.#leaveRoom(roomId, live);
876
883
  SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
877
884
  SignalResolver.#liveWsMessageCtx.delete(ws);
885
+ await SignalResolver.#runLifecycleHandlers(contexts, ["unsubscribe", "disconnect"]);
886
+
887
+ for (const roomId of roomCtxMap?.keys() ?? []) SignalResolver.#leaveRoom(roomId, live);
878
888
 
879
889
  await SignalResolver.#getWebsocket(registry).unregisterSocket(ws);
880
890
  SignalResolver.logger.verbose(`WebSocket disconnected from all rooms`);
@@ -5,6 +5,7 @@ export const AKAN_RSC_STATE_VERSION_HEADER = "X-Akan-Rsc-State-Version";
5
5
  export const AKAN_RSC_CURRENT_ROUTE_HEADER = "X-Akan-Rsc-Current-Route";
6
6
  export const AKAN_RSC_CURRENT_STATE_HEADER = "X-Akan-Rsc-Current-State";
7
7
  export const AKAN_RSC_RESPONSE_STATE_HEADER = "X-Akan-Rsc-State";
8
+ export const AKAN_RSC_NOT_FOUND_HEADER = "X-Akan-Rsc-Not-Found";
8
9
  export const AKAN_RSC_PATCH_START_INDEX_HEADER = "X-Akan-Rsc-Patch-Start-Index";
9
10
  export const AKAN_RSC_PATCH_SEGMENT_PATH_HEADER = "X-Akan-Rsc-Patch-Segment-Path";
10
11
  export const AKAN_RSC_PATCH_START_SEGMENT_HEADER = "X-Akan-Rsc-Patch-Start-Segment";