akanjs 2.2.11 → 2.2.13-rc.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # akanjs
2
2
 
3
+ ## 2.2.12
4
+
5
+ ### Patch Changes
6
+
7
+ - 666e46c: Improve SSR hydration payload handling, redirect status propagation, and restore dev HMR incremental refresh behavior.
8
+ - 666e46c: Align RSC not-found responses with HTTP 404 semantics and add request-scoped policy tracking for future cache decisions.
9
+
3
10
  ## 2.2.11
4
11
 
5
12
  ### Patch Changes
package/client/cookie.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { getEnv } from "akanjs/base";
2
2
  import { decodeJwtPayload, Logger } from "akanjs/common";
3
3
  import type { Account } from "akanjs/fetch";
4
- import { requestStorage } from "akanjs/fetch";
4
+ import { cookies as serverCookies, headers as serverHeaders } from "akanjs/fetch";
5
5
  import { loadCapacitorCore } from "./capacitor";
6
6
  import { storage } from "./storage";
7
7
  import { fetch } from "./useClient";
@@ -30,11 +30,7 @@ function parseCookieHeader(cookieHeader: string): Map<string, { name: string; va
30
30
  }
31
31
 
32
32
  export const cookies = (): Map<string, { name: string; value: string }> => {
33
- if (getEnv().side === "server") {
34
- const req = requestStorage?.getStore();
35
- if (!req) return new Map();
36
- return parseCookieHeader(req.headers.get("cookie") ?? "");
37
- }
33
+ if (getEnv().side === "server") return serverCookies();
38
34
  return parseCookieHeader(document.cookie);
39
35
  };
40
36
 
@@ -75,13 +71,7 @@ export const removeCookie = (key: string, options: { path: string } = { path: "/
75
71
  };
76
72
  export const headers = (): Map<string, string> => {
77
73
  if (getEnv().side !== "server") return new Map();
78
- const req = requestStorage?.getStore();
79
- if (!req) return new Map();
80
- const map = new Map<string, string>();
81
- req.headers.forEach((value, key) => {
82
- map.set(key, value);
83
- });
84
- return map;
74
+ return serverHeaders();
85
75
  };
86
76
 
87
77
  export const getHeader = (key: string): string | undefined => {
package/client/router.ts CHANGED
@@ -35,12 +35,18 @@ interface CSRClientRouterOption extends RouterOptions {
35
35
  router: RouterInstance;
36
36
  }
37
37
  export type RedirectMethod = "replace" | "push";
38
+ export type RedirectStatus = 303 | 307 | 308;
39
+ export interface RedirectOptions {
40
+ method?: RedirectMethod;
41
+ status?: RedirectStatus;
42
+ }
38
43
 
39
44
  export class AkanRedirectError extends Error {
40
45
  readonly digest = "AKAN_REDIRECT";
41
46
  constructor(
42
47
  readonly location: string,
43
48
  readonly method: RedirectMethod = "replace",
49
+ readonly status: RedirectStatus = 307,
44
50
  ) {
45
51
  super(`Redirect to ${location}`);
46
52
  this.name = "AkanRedirectError";
@@ -246,7 +252,9 @@ class Router {
246
252
  this.#instance.refresh();
247
253
  return undefined as never;
248
254
  }
249
- redirect(href: string): never {
255
+ redirect(href: string, options: RedirectOptions = {}): never {
256
+ const method = options.method ?? "replace";
257
+ const status = options.status ?? 307;
250
258
  if (getEnv().side === "server") {
251
259
  const { getRequest, headers: requestHeaders } = getServerRequestContext();
252
260
  const h = requestHeaders();
@@ -257,9 +265,9 @@ class Router {
257
265
  const basePath = getServerBasePath(reqPathname, lang, h.get("x-base-path") ?? undefined, this.#prefix);
258
266
  const { pathname, href: fullHref } = getPathInfo(href, lang, shouldExposeBasePath() ? basePath : "");
259
267
  Logger.log(`redirect to:${pathname}`);
260
- throw new AkanRedirectError(fullHref, "replace");
268
+ throw new AkanRedirectError(fullHref, method, status);
261
269
  } else {
262
- this.#instance.replace(href);
270
+ this.#instance[method](href);
263
271
  }
264
272
  return undefined as never;
265
273
  }
@@ -1,15 +1,35 @@
1
- export interface RequestStorage {
2
- run<T>(store: Request, callback: () => T): T;
3
- getStore(): Request | undefined;
1
+ export type AkanTheme = "css" | "system" | (string & {});
2
+
3
+ export interface AkanRequestPolicy {
4
+ routeId?: string;
5
+ rscCache?: "public" | false;
6
+ rscCacheTtl?: number;
7
+ cacheable?: boolean;
8
+ revalidate?: number | false;
9
+ tags: Set<string>;
4
10
  }
5
11
 
6
- export type AkanTheme = "css" | "system" | (string & {});
12
+ export interface AkanDynamicUsage {
13
+ headers: boolean;
14
+ cookies: boolean;
15
+ }
16
+
17
+ export interface AkanRequestStore {
18
+ request: Request;
19
+ theme?: AkanTheme;
20
+ queryCache: Map<string, Promise<unknown>>;
21
+ policy: AkanRequestPolicy;
22
+ dynamicUsage: AkanDynamicUsage;
23
+ }
24
+
25
+ export interface RequestStorage {
26
+ run<T>(store: Request | AkanRequestStore, callback: () => T): T;
27
+ getStore(): AkanRequestStore | undefined;
28
+ }
7
29
 
8
30
  declare global {
9
31
  var __AKAN_REQUEST_STORAGE__: RequestStorage | undefined;
10
- var __AKAN_REQUEST_THEME__: WeakMap<Request, AkanTheme> | undefined;
11
- var __AKAN_REQUEST_QUERY_CACHE__: WeakMap<Request, Map<string, Promise<unknown>>> | undefined;
12
- var __AKAN_REQUEST_FALLBACK_STACK__: Request[] | undefined;
32
+ var __AKAN_REQUEST_FALLBACK_STACK__: AkanRequestStore[] | undefined;
13
33
  }
14
34
 
15
35
  let _requestStorage: RequestStorage | null = null;
@@ -17,74 +37,120 @@ if (typeof window === "undefined") {
17
37
  try {
18
38
 
19
39
  const { AsyncLocalStorage } = require("node:async_hooks") as typeof import("node:async_hooks");
20
- globalThis.__AKAN_REQUEST_STORAGE__ ??= new AsyncLocalStorage() as RequestStorage;
40
+ const als = new AsyncLocalStorage<AkanRequestStore>();
41
+ globalThis.__AKAN_REQUEST_STORAGE__ ??= {
42
+ run<T>(store: Request | AkanRequestStore, callback: () => T): T {
43
+ return als.run(normalizeRequestStore(store), callback);
44
+ },
45
+ getStore(): AkanRequestStore | undefined {
46
+ return als.getStore();
47
+ },
48
+ };
21
49
  _requestStorage = globalThis.__AKAN_REQUEST_STORAGE__;
22
50
  } catch {}
23
51
  }
24
52
 
25
53
  export const requestStorage: RequestStorage | null = _requestStorage;
26
54
 
27
- function requestThemeMap(): WeakMap<Request, AkanTheme> {
28
- globalThis.__AKAN_REQUEST_THEME__ ??= new WeakMap<Request, AkanTheme>();
29
- return globalThis.__AKAN_REQUEST_THEME__;
55
+ function createRequestPolicy(): AkanRequestPolicy {
56
+ return { tags: new Set() };
57
+ }
58
+
59
+ export function createRequestStore(
60
+ request: Request,
61
+ policy: Partial<Omit<AkanRequestPolicy, "tags">> = {},
62
+ ): AkanRequestStore {
63
+ return {
64
+ request,
65
+ queryCache: new Map(),
66
+ policy: { ...createRequestPolicy(), ...policy },
67
+ dynamicUsage: { headers: false, cookies: false },
68
+ };
69
+ }
70
+
71
+ function isRequestStore(store: Request | AkanRequestStore | undefined): store is AkanRequestStore {
72
+ return Boolean(store && typeof store === "object" && "request" in store && store.request instanceof Request);
73
+ }
74
+
75
+ function normalizeRequestStore(store: Request | AkanRequestStore): AkanRequestStore {
76
+ return isRequestStore(store) ? store : createRequestStore(store);
30
77
  }
31
78
 
32
- function requestQueryCacheMap(): WeakMap<Request, Map<string, Promise<unknown>>> {
33
- globalThis.__AKAN_REQUEST_QUERY_CACHE__ ??= new WeakMap<Request, Map<string, Promise<unknown>>>();
34
- return globalThis.__AKAN_REQUEST_QUERY_CACHE__;
79
+ function getActiveRequestStore(): AkanRequestStore | undefined {
80
+ const store = requestStorage?.getStore() as Request | AkanRequestStore | undefined;
81
+ if (store) return isRequestStore(store) ? store : createRequestStore(store);
82
+ return globalThis.__AKAN_REQUEST_FALLBACK_STACK__?.at(-1);
35
83
  }
36
84
 
37
85
  /** Stores theme preference on the active request when server rendering. */
38
86
  export function setRequestTheme(theme: AkanTheme | undefined): void {
39
- const req = getRequest();
40
- if (!req || theme === undefined) return;
41
- requestThemeMap().set(req, theme);
87
+ const store = getRequestStore();
88
+ if (!store || theme === undefined) return;
89
+ store.theme = theme;
42
90
  }
43
91
 
44
92
  export function getRequestTheme(): AkanTheme | undefined {
45
- const req = getRequest();
46
- if (!req) return undefined;
47
- return requestThemeMap().get(req);
93
+ return getRequestStore()?.theme;
48
94
  }
49
95
 
50
96
  export function pushRequestFallback(req: Request): () => void {
51
97
  globalThis.__AKAN_REQUEST_FALLBACK_STACK__ ??= [];
52
98
  const stack = globalThis.__AKAN_REQUEST_FALLBACK_STACK__;
53
- stack.push(req);
99
+ const store = createRequestStore(req);
100
+ stack.push(store);
54
101
  return () => {
55
- const index = stack.lastIndexOf(req);
102
+ const index = stack.lastIndexOf(store);
56
103
  if (index >= 0) stack.splice(index, 1);
57
104
  };
58
105
  }
59
106
 
107
+ /** Returns the active server request store from AsyncLocalStorage or the fallback stack. */
108
+ export function getRequestStore(): AkanRequestStore | undefined {
109
+ return getActiveRequestStore();
110
+ }
111
+
60
112
  /** Returns the active server request from AsyncLocalStorage or the fallback stack. */
61
113
  export function getRequest(): Request | undefined {
62
- return requestStorage?.getStore() ?? globalThis.__AKAN_REQUEST_FALLBACK_STACK__?.at(-1);
114
+ return getRequestStore()?.request;
115
+ }
116
+
117
+ export function getRequestPolicy(): AkanRequestPolicy | undefined {
118
+ return getRequestStore()?.policy;
119
+ }
120
+
121
+ export function updateRequestPolicy(
122
+ patch: Partial<Omit<AkanRequestPolicy, "tags">> & { tags?: Iterable<string> },
123
+ ): AkanRequestPolicy | undefined {
124
+ const policy = getRequestPolicy();
125
+ if (!policy) return undefined;
126
+ const { tags, ...rest } = patch;
127
+ Object.assign(policy, rest);
128
+ if (tags) for (const tag of tags) policy.tags.add(tag);
129
+ return policy;
130
+ }
131
+
132
+ export function getRequestDynamicUsage(): AkanDynamicUsage | undefined {
133
+ return getRequestStore()?.dynamicUsage;
63
134
  }
64
135
 
65
136
  /** Deduplicates a promise-producing query within the active request. */
66
137
  export function memoizeRequestQuery<T>(key: string, factory: () => Promise<T>): Promise<T> {
67
- const req = getRequest();
68
- if (!req) return factory();
69
- const cacheMap = requestQueryCacheMap();
70
- let cache = cacheMap.get(req);
71
- if (!cache) {
72
- cache = new Map<string, Promise<unknown>>();
73
- cacheMap.set(req, cache);
74
- }
75
- const existing = cache.get(key);
138
+ const store = getRequestStore();
139
+ if (!store) return factory();
140
+ const existing = store.queryCache.get(key);
76
141
  if (existing) return existing as Promise<T>;
77
142
  const promise = factory();
78
- cache.set(key, promise);
143
+ store.queryCache.set(key, promise);
79
144
  return promise;
80
145
  }
81
146
 
82
147
  /** Returns current request headers as a Map, or an empty Map outside a request. */
83
148
  export function headers(): Map<string, string> {
84
- const req = getRequest();
149
+ const store = getRequestStore();
85
150
  const map = new Map<string, string>();
86
- if (!req) return map;
87
- req.headers.forEach((value, key) => {
151
+ if (!store) return map;
152
+ store.dynamicUsage.headers = true;
153
+ store.request.headers.forEach((value, key) => {
88
154
  map.set(key, value);
89
155
  });
90
156
  return map;
@@ -121,7 +187,8 @@ export function parseCookieHeader(cookieHeader: string): Map<string, CookieEntry
121
187
 
122
188
  /** Returns parsed cookies from the current request, or an empty Map outside a request. */
123
189
  export function cookies(): Map<string, CookieEntry> {
124
- const req = getRequest();
125
- if (!req) return new Map();
126
- return parseCookieHeader(req.headers.get("cookie") ?? "");
190
+ const store = getRequestStore();
191
+ if (!store) return new Map();
192
+ store.dynamicUsage.cookies = true;
193
+ return parseCookieHeader(store.request.headers.get("cookie") ?? "");
127
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.2.11",
3
+ "version": "2.2.13-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -84,6 +84,8 @@ export class AkanApp {
84
84
  #logWriter: RotatingLogWriter | null = null;
85
85
  #removeLogSink: (() => void) | null = null;
86
86
  readonly #childOutputBuffers = new Map<string, string>();
87
+ readonly #childStderrBlockBuffers = new Map<string, string[]>();
88
+ readonly #childStderrBlockTimers = new Map<string, ReturnType<typeof setTimeout>>();
87
89
  static readonly #ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
88
90
  #gatewayMetrics: AkanMetricsReport = {};
89
91
  #proxyHopCount = 0;
@@ -1004,6 +1006,7 @@ export class AkanApp {
1004
1006
  if (remaining) this.#writeChildOutput(idx, role, type, bufferKey, remaining);
1005
1007
  } finally {
1006
1008
  this.#flushChildOutput(idx, role, type, bufferKey);
1009
+ if (type === "stderr") this.#flushChildStderrBlock(idx, role, AkanApp.#childStderrBlockKey(idx, role));
1007
1010
  }
1008
1011
  }
1009
1012
 
@@ -1028,11 +1031,63 @@ export class AkanApp {
1028
1031
  }
1029
1032
 
1030
1033
  #writeChildOutputLine(idx: number, role: AkanChildRole, type: "stdout" | "stderr", line: string) {
1034
+ if (type === "stderr" && this.#bufferChildStderrLine(idx, role, line)) return;
1035
+ this.#writeChildOutputLineRaw(idx, role, type, line);
1036
+ }
1037
+
1038
+ #writeChildOutputLineRaw(idx: number, role: AkanChildRole, type: "stdout" | "stderr", line: string) {
1031
1039
  const prefixedLine = `[child:${idx} ${role}] [${type}] ${line}`;
1032
1040
  process[type].write(prefixedLine);
1033
1041
  this.#logWriter?.write(`${idx}-${role}`, AkanApp.#stripAnsi(prefixedLine));
1034
1042
  }
1035
1043
 
1044
+ #bufferChildStderrLine(idx: number, role: AkanChildRole, line: string): boolean {
1045
+ const key = AkanApp.#childStderrBlockKey(idx, role);
1046
+ const block = this.#childStderrBlockBuffers.get(key) ?? [];
1047
+ block.push(line);
1048
+ this.#childStderrBlockBuffers.set(key, block);
1049
+
1050
+ const existingTimer = this.#childStderrBlockTimers.get(key);
1051
+ if (existingTimer) clearTimeout(existingTimer);
1052
+
1053
+ if (line.trim() === "" || block.length >= 64) {
1054
+ this.#flushChildStderrBlock(idx, role, key);
1055
+ return true;
1056
+ }
1057
+
1058
+ this.#childStderrBlockTimers.set(
1059
+ key,
1060
+ setTimeout(() => this.#flushChildStderrBlock(idx, role, key), 50),
1061
+ );
1062
+ return true;
1063
+ }
1064
+
1065
+ #flushChildStderrBlock(idx: number, role: AkanChildRole, key: string) {
1066
+ const timer = this.#childStderrBlockTimers.get(key);
1067
+ if (timer) clearTimeout(timer);
1068
+ this.#childStderrBlockTimers.delete(key);
1069
+
1070
+ const block = this.#childStderrBlockBuffers.get(key);
1071
+ if (!block?.length) return;
1072
+ this.#childStderrBlockBuffers.delete(key);
1073
+
1074
+ const text = block.join("");
1075
+ if (AkanApp.#isBenignRsdwConnectionClosedBlock(text)) return;
1076
+ for (const blockLine of block) this.#writeChildOutputLineRaw(idx, role, "stderr", blockLine);
1077
+ }
1078
+
1079
+ static #childStderrBlockKey(idx: number, role: AkanChildRole): string {
1080
+ return `${idx}:${role}:stderr`;
1081
+ }
1082
+
1083
+ static #isBenignRsdwConnectionClosedBlock(text: string): boolean {
1084
+ return (
1085
+ text.includes('reportGlobalError(weakResponse, Error("Connection closed."))') &&
1086
+ text.includes("error: Connection closed.") &&
1087
+ text.includes("react-server-dom-webpack")
1088
+ );
1089
+ }
1090
+
1036
1091
  static #stripAnsi(msg: string) {
1037
1092
  return msg.replace(AkanApp.#ansiPattern, "");
1038
1093
  }
@@ -45,11 +45,11 @@ export const HMR_CLIENT_SCRIPT = `(function(){
45
45
  return;
46
46
  }
47
47
  if (msg.type === "rsc-refresh") {
48
- reloadForHmr(msg);
48
+ refreshRsc(msg);
49
49
  return;
50
50
  }
51
51
  if (msg.type === "client-refresh") {
52
- reloadForHmr(msg);
52
+ refreshClient(msg);
53
53
  return;
54
54
  }
55
55
  if (msg.type === "css-update") {
@@ -73,12 +73,6 @@ export const HMR_CLIENT_SCRIPT = `(function(){
73
73
  setTimeout(connect, delay);
74
74
  }
75
75
 
76
- function reloadForHmr(msg){
77
- try { self.__AKAN_RSC_CLEAR_CACHE__ && self.__AKAN_RSC_CLEAR_CACHE__(); } catch(e){}
78
- if (msg && msg.buildId != null) lastBuildId = msg.buildId;
79
- location.reload();
80
- }
81
-
82
76
  function ensureOverlay(){
83
77
  if (overlayEl && overlayLabelEl) return overlayEl;
84
78
  if (!overlayStyleEl) {
@@ -1,11 +1,14 @@
1
1
  import { createElement, type ReactNode, startTransition, use, useLayoutEffect, useState } from "react";
2
2
  import { hydrateRoot } from "react-dom/client";
3
3
  import { createFromReadableStream } from "react-server-dom-webpack/client.browser";
4
+ import { isRscPayloadResponse } from "./rscHttp";
5
+
6
+ type InlineRscChunk = [1, string] | [3, string];
4
7
 
5
8
  declare global {
6
- var __RSC_CHUNKS__: string[] | undefined;
9
+ var __RSC_CHUNKS__: InlineRscChunk[] | undefined;
7
10
  var __RSC_CLOSED__: boolean | undefined;
8
- var __RSC_PUSH__: ((b64: string) => void) | undefined;
11
+ var __RSC_PUSH__: ((type: InlineRscChunk[0], data: string) => void) | undefined;
9
12
  var __RSC_CLOSE__: (() => void) | undefined;
10
13
  var __AKAN_RSC_NAVIGATE__:
11
14
  | ((href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => Promise<void>)
@@ -21,15 +24,20 @@ function decodeBase64(b64: string): Uint8Array {
21
24
  return bytes;
22
25
  }
23
26
 
27
+ function decodeInlineRscChunk([type, data]: InlineRscChunk): Uint8Array {
28
+ if (type === 1) return new TextEncoder().encode(data);
29
+ return decodeBase64(data);
30
+ }
31
+
24
32
  type RscThenable = Promise<ReactNode>;
25
- type RscFetchResult = { type: "rsc"; thenable: RscThenable } | { type: "redirected" };
33
+ type RscFetchResult = { type: "rsc"; thenable: RscThenable } | { type: "redirected"; status?: number };
26
34
  const MAX_RSC_CACHE_ENTRIES = 32;
27
35
 
28
36
  function createInitialRscStream(): ReadableStream<Uint8Array> {
29
37
  return new ReadableStream<Uint8Array>({
30
38
  start(controller) {
31
39
  const queued = globalThis.__RSC_CHUNKS__ ?? [];
32
- for (const b64 of queued) controller.enqueue(decodeBase64(b64));
40
+ for (const chunk of queued) controller.enqueue(decodeInlineRscChunk(chunk));
33
41
  globalThis.__RSC_CHUNKS__ = [];
34
42
 
35
43
  if (globalThis.__RSC_CLOSED__) {
@@ -37,7 +45,7 @@ function createInitialRscStream(): ReadableStream<Uint8Array> {
37
45
  return;
38
46
  }
39
47
 
40
- globalThis.__RSC_PUSH__ = (b64: string) => controller.enqueue(decodeBase64(b64));
48
+ globalThis.__RSC_PUSH__ = (type, data) => controller.enqueue(decodeInlineRscChunk([type, data]));
41
49
  globalThis.__RSC_CLOSE__ = () => controller.close();
42
50
  },
43
51
  });
@@ -63,10 +71,12 @@ async function fetchRsc(href: string, options: { buildId?: number } = {}): Promi
63
71
  const redirect = res.headers.get("X-Akan-Redirect");
64
72
  if (redirect) {
65
73
  const method = res.headers.get("X-Akan-Redirect-Method");
74
+ const statusHeader = res.headers.get("X-Akan-Redirect-Status");
75
+ const status = statusHeader ? Number(statusHeader) : undefined;
66
76
  await globalThis.__AKAN_RSC_NAVIGATE__?.(redirect, { replace: method !== "push", scrollToTop: true });
67
- return { type: "redirected" };
77
+ return { type: "redirected", status };
68
78
  }
69
- if (!res.ok || !res.body) throw new Error(`[rscClient] RSC fetch failed ${res.status} ${res.statusText}`);
79
+ if (!isRscPayloadResponse(res)) throw new Error(`[rscClient] RSC fetch failed ${res.status} ${res.statusText}`);
70
80
 
71
81
  const buffer = await res.arrayBuffer();
72
82
  const completeStream = new ReadableStream<Uint8Array>({
@@ -0,0 +1,7 @@
1
+ export const RSC_CONTENT_TYPE = "text/x-component; charset=utf-8";
2
+
3
+ export function isRscPayloadResponse(res: Response): boolean {
4
+ if (!res.body) return false;
5
+ if (res.ok) return true;
6
+ return res.status === 404 && (res.headers.get("Content-Type") ?? "").toLowerCase().startsWith("text/x-component");
7
+ }