akanjs 2.2.12 → 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.
@@ -5,14 +5,18 @@ import { type AkanI18nConfig, DEFAULT_AKAN_I18N, Logger } from "akanjs/common";
5
5
  import type { AkanTheme } from "akanjs/fetch";
6
6
  import type { AkanMetricsReport } from "akanjs/service";
7
7
  import type { ClientManifest } from "./artifact";
8
+ import type { SsrLateRedirect } from "./ssrTypes";
8
9
  import type { BaseBuildArtifact, CssAsset } from "./types";
9
10
 
10
- interface RscPending {
11
+ const DEFAULT_RSC_HOST_MAX_PENDING_CHUNKS = 256;
12
+
13
+ export interface RscPending {
11
14
  onChunk: (data: Uint8Array) => void;
12
15
  onEnd: () => void;
13
16
  onError: (message: string) => void;
14
17
  onMeta?: (meta: { theme?: AkanTheme; status?: number }) => void;
15
18
  onRedirect?: (location: string, method: RscRedirectMethod, status: RscRedirectStatus) => void;
19
+ onLateRedirect?: (location: string, method: RscRedirectMethod, status: RscRedirectStatus) => void;
16
20
  onNotFound?: () => void;
17
21
  }
18
22
 
@@ -20,10 +24,182 @@ export type RscRedirectMethod = "replace" | "push";
20
24
  export type RscRedirectStatus = 303 | 307 | 308;
21
25
 
22
26
  export type RscRenderResult =
23
- | { type: "stream"; stream: ReadableStream<Uint8Array>; theme?: AkanTheme; status?: number }
27
+ | {
28
+ type: "stream";
29
+ stream: ReadableStream<Uint8Array>;
30
+ theme?: AkanTheme;
31
+ status?: number;
32
+ lateControl: Promise<SsrLateRedirect | null>;
33
+ cancel: (reason?: unknown) => void;
34
+ }
24
35
  | { type: "redirect"; location: string; method: RscRedirectMethod; status: RscRedirectStatus }
25
36
  | { type: "not-found" };
26
37
 
38
+ export function getRscHostMaxPendingChunks(value = process.env.AKAN_RSC_HOST_MAX_PENDING_CHUNKS): number {
39
+ const parsed = Number.parseInt(value ?? "", 10);
40
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_RSC_HOST_MAX_PENDING_CHUNKS;
41
+ }
42
+
43
+ export function nextRscHostPendingChunkCount(currentPendingChunks: number, desiredSize: number | null): number {
44
+ return desiredSize !== null && desiredSize <= 0 ? currentPendingChunks + 1 : 0;
45
+ }
46
+
47
+ export function isRscHostPendingChunkOverflow(pendingChunks: number, maxPendingChunks: number): boolean {
48
+ return pendingChunks > maxPendingChunks;
49
+ }
50
+
51
+ function createRscRenderAbortError(reason?: unknown): Error {
52
+ if (reason instanceof Error) return reason;
53
+ const error = new Error(reason === undefined ? "rsc render aborted" : String(reason));
54
+ error.name = "AbortError";
55
+ return error;
56
+ }
57
+
58
+ export function createIdempotentRscRenderCancel(onCancel: (reason?: unknown) => void): (reason?: unknown) => void {
59
+ let cancelled = false;
60
+ return (reason?: unknown) => {
61
+ if (cancelled) return;
62
+ cancelled = true;
63
+ onCancel(reason);
64
+ };
65
+ }
66
+
67
+ export function createRscHostRenderStream(input: {
68
+ setPending: (pending: RscPending) => void;
69
+ deletePending: () => void;
70
+ sendRenderOrQueue: () => void;
71
+ cancelRender: (reason?: unknown) => void;
72
+ maxPendingChunks?: number;
73
+ signal?: AbortSignal;
74
+ onPendingChunkOverflow?: () => void;
75
+ }): Promise<RscRenderResult> {
76
+ let settled = false;
77
+ let stream!: ReadableStream<Uint8Array>;
78
+ let theme: AkanTheme | undefined;
79
+ let status: number | undefined;
80
+ let resolveLateControl!: (control: SsrLateRedirect | null) => void;
81
+ const lateControl = new Promise<SsrLateRedirect | null>((resolve) => {
82
+ resolveLateControl = resolve;
83
+ });
84
+ let lateControlSettled = false;
85
+ const settleLateControl = (control: SsrLateRedirect | null) => {
86
+ if (lateControlSettled) return;
87
+ lateControlSettled = true;
88
+ resolveLateControl(control);
89
+ };
90
+ const maxPendingChunks = input.maxPendingChunks ?? getRscHostMaxPendingChunks();
91
+ let pendingChunks = 0;
92
+ let removeAbortListener: (() => void) | undefined;
93
+ const cleanupAbortListener = () => {
94
+ removeAbortListener?.();
95
+ removeAbortListener = undefined;
96
+ };
97
+ const cancelRender = createIdempotentRscRenderCancel((reason) => {
98
+ input.deletePending();
99
+ settleLateControl(null);
100
+ input.cancelRender(reason);
101
+ cleanupAbortListener();
102
+ });
103
+
104
+ return new Promise<RscRenderResult>((resolve, reject) => {
105
+ stream = new ReadableStream<Uint8Array>({
106
+ start: (controller) => {
107
+ const abortRender = () => {
108
+ const error = createRscRenderAbortError(input.signal?.reason);
109
+ cancelRender(error);
110
+ if (!settled) {
111
+ settled = true;
112
+ reject(error);
113
+ return;
114
+ }
115
+ controller.error(error);
116
+ };
117
+ const settleStream = () => {
118
+ if (settled) return;
119
+ settled = true;
120
+ resolve({ type: "stream", stream, theme, status, lateControl, cancel: cancelRender });
121
+ };
122
+ input.setPending({
123
+ onMeta: (meta) => {
124
+ theme = meta.theme;
125
+ status = meta.status;
126
+ settleStream();
127
+ },
128
+ onChunk: (data) => {
129
+ settleStream();
130
+ pendingChunks = nextRscHostPendingChunkCount(pendingChunks, controller.desiredSize);
131
+ if (isRscHostPendingChunkOverflow(pendingChunks, maxPendingChunks)) {
132
+ const msg = `rsc worker host queue exceeded ${maxPendingChunks} pending chunks`;
133
+ const error = new Error(msg);
134
+ input.onPendingChunkOverflow?.();
135
+ cancelRender(error);
136
+ controller.error(error);
137
+ return;
138
+ }
139
+ controller.enqueue(data);
140
+ },
141
+ onEnd: () => {
142
+ settleLateControl(null);
143
+ cleanupAbortListener();
144
+ settleStream();
145
+ controller.close();
146
+ },
147
+ onError: (msg) => {
148
+ settleLateControl(null);
149
+ cleanupAbortListener();
150
+ if (!settled) {
151
+ settled = true;
152
+ reject(new Error(msg));
153
+ return;
154
+ }
155
+ controller.error(new Error(msg));
156
+ },
157
+ onRedirect: (location, method, status) => {
158
+ settleLateControl(null);
159
+ cleanupAbortListener();
160
+ if (!settled) {
161
+ settled = true;
162
+ resolve({ type: "redirect", location, method, status });
163
+ controller.close();
164
+ return;
165
+ }
166
+ controller.error(new Error(`redirect after stream started: ${location}`));
167
+ },
168
+ onLateRedirect: (location, method, status) => {
169
+ settleLateControl({ type: "redirect", location, method, status });
170
+ },
171
+ onNotFound: () => {
172
+ settleLateControl(null);
173
+ cleanupAbortListener();
174
+ if (!settled) {
175
+ settled = true;
176
+ resolve({ type: "not-found" });
177
+ controller.close();
178
+ return;
179
+ }
180
+ controller.error(new Error("not-found after stream started"));
181
+ },
182
+ });
183
+ if (input.signal) {
184
+ if (input.signal.aborted) {
185
+ abortRender();
186
+ return;
187
+ }
188
+ input.signal.addEventListener("abort", abortRender, { once: true });
189
+ removeAbortListener = () => input.signal?.removeEventListener("abort", abortRender);
190
+ }
191
+ input.sendRenderOrQueue();
192
+ },
193
+ cancel: (reason) => {
194
+ cancelRender(reason);
195
+ },
196
+ pull: () => {
197
+ pendingChunks = Math.max(0, pendingChunks - 1);
198
+ },
199
+ });
200
+ });
201
+ }
202
+
27
203
  type RscInMsg =
28
204
  | { type: "hello" }
29
205
  | { type: "ready" }
@@ -32,6 +208,13 @@ type RscInMsg =
32
208
  | { type: "chunk"; requestId: string; data: Uint8Array }
33
209
  | { type: "end"; requestId: string }
34
210
  | { type: "redirect"; requestId: string; location: string; method?: RscRedirectMethod; status?: RscRedirectStatus }
211
+ | {
212
+ type: "late-redirect";
213
+ requestId: string;
214
+ location: string;
215
+ method?: RscRedirectMethod;
216
+ status?: RscRedirectStatus;
217
+ }
35
218
  | { type: "not-found"; requestId: string }
36
219
  | { type: "metrics"; metrics: AkanMetricsReport }
37
220
  | { type: "error"; requestId: string; message: string; buildId?: number };
@@ -106,6 +289,7 @@ export class RscWorker {
106
289
  #recycleCount = 0;
107
290
  #lastRecycleReason: string | undefined;
108
291
  #lastWorkerMetrics: AkanMetricsReport = {};
292
+ #hostPendingChunkOverflowCount = 0;
109
293
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
110
294
  #recycleTimer: ReturnType<typeof setTimeout> | null = null;
111
295
  #rollingRecycle: { oldProc: Bun.Subprocess<"ignore", "inherit", "inherit">; reason: string } | null = null;
@@ -172,73 +356,34 @@ export class RscWorker {
172
356
  },
173
357
  cancel: () => {
174
358
  this.#pending.delete(requestId);
359
+ this.#cancelRender(requestId);
175
360
  },
176
361
  });
177
362
  }
178
363
 
179
- renderWithMeta(req: Request, options: { clientManifest?: ClientManifest } = {}): Promise<RscRenderResult> {
364
+ renderWithMeta(
365
+ req: Request,
366
+ options: { clientManifest?: ClientManifest; signal?: AbortSignal } = {},
367
+ ): Promise<RscRenderResult> {
180
368
  const requestId = crypto.randomUUID();
181
- let settled = false;
182
- let stream!: ReadableStream<Uint8Array>;
183
- let theme: AkanTheme | undefined;
184
- let status: number | undefined;
185
- const result = new Promise<RscRenderResult>((resolve, reject) => {
186
- stream = new ReadableStream<Uint8Array>({
187
- start: (controller) => {
188
- const settleStream = () => {
189
- if (settled) return;
190
- settled = true;
191
- resolve({ type: "stream", stream, theme, status });
192
- };
193
- this.#pending.set(requestId, {
194
- onMeta: (meta) => {
195
- theme = meta.theme;
196
- status = meta.status;
197
- settleStream();
198
- },
199
- onChunk: (data) => {
200
- settleStream();
201
- controller.enqueue(data);
202
- },
203
- onEnd: () => {
204
- settleStream();
205
- controller.close();
206
- },
207
- onError: (msg) => {
208
- if (!settled) {
209
- settled = true;
210
- reject(new Error(msg));
211
- return;
212
- }
213
- controller.error(new Error(msg));
214
- },
215
- onRedirect: (location, method, status) => {
216
- if (!settled) {
217
- settled = true;
218
- resolve({ type: "redirect", location, method, status });
219
- controller.close();
220
- return;
221
- }
222
- controller.error(new Error(`redirect after stream started: ${location}`));
223
- },
224
- onNotFound: () => {
225
- if (!settled) {
226
- settled = true;
227
- resolve({ type: "not-found" });
228
- controller.close();
229
- return;
230
- }
231
- controller.error(new Error("not-found after stream started"));
232
- },
233
- });
234
- this.#sendRenderOrQueue(requestId, req, options.clientManifest);
235
- },
236
- cancel: () => {
237
- this.#pending.delete(requestId);
238
- },
239
- });
369
+ return createRscHostRenderStream({
370
+ setPending: (pending) => {
371
+ this.#pending.set(requestId, pending);
372
+ },
373
+ deletePending: () => {
374
+ this.#pending.delete(requestId);
375
+ },
376
+ sendRenderOrQueue: () => {
377
+ this.#sendRenderOrQueue(requestId, req, options.clientManifest);
378
+ },
379
+ cancelRender: () => {
380
+ this.#cancelRender(requestId);
381
+ },
382
+ signal: options.signal,
383
+ onPendingChunkOverflow: () => {
384
+ this.#hostPendingChunkOverflowCount += 1;
385
+ },
240
386
  });
241
- return result;
242
387
  }
243
388
 
244
389
  kill(): void {
@@ -258,8 +403,18 @@ export class RscWorker {
258
403
  }
259
404
 
260
405
  getMetrics(): AkanMetricsReport {
261
- return {
262
- ...this.#lastWorkerMetrics,
406
+ const {
407
+ rscWorkerPid: _rscWorkerPid,
408
+ rscWorkerStatus: _rscWorkerStatus,
409
+ rscWorkerRestartCount: _rscWorkerRestartCount,
410
+ rscWorkerRecycleCount: _rscWorkerRecycleCount,
411
+ rscWorkerLastRecycleReason: _rscWorkerLastRecycleReason,
412
+ rscPendingRenderCount: _rscPendingRenderCount,
413
+ rscQueuedSendCount: _rscQueuedSendCount,
414
+ rscHostPendingChunkOverflowCount: _rscHostPendingChunkOverflowCount,
415
+ ...workerMetrics
416
+ } = this.#lastWorkerMetrics;
417
+ return Object.assign(workerMetrics, {
263
418
  rscWorkerPid: this.#proc.pid,
264
419
  rscWorkerStatus: this.#status,
265
420
  rscWorkerRestartCount: this.#restartCount,
@@ -267,7 +422,8 @@ export class RscWorker {
267
422
  rscWorkerLastRecycleReason: this.#lastRecycleReason,
268
423
  rscPendingRenderCount: this.#pending.size,
269
424
  rscQueuedSendCount: this.#queuedSends.length,
270
- };
425
+ rscHostPendingChunkOverflowCount: this.#hostPendingChunkOverflowCount,
426
+ });
271
427
  }
272
428
 
273
429
  restartWhenIdle(reason: string): boolean {
@@ -411,6 +567,11 @@ export class RscWorker {
411
567
  p.onRedirect?.(message.location, message.method ?? "replace", message.status ?? 307),
412
568
  );
413
569
  return;
570
+ case "late-redirect":
571
+ this.#pending
572
+ .get(message.requestId)
573
+ ?.onLateRedirect?.(message.location, message.method ?? "replace", message.status ?? 307);
574
+ return;
414
575
  case "not-found":
415
576
  this.#resolvePending(message.requestId, (p) => p.onNotFound?.());
416
577
  return;
@@ -470,6 +631,14 @@ export class RscWorker {
470
631
  }
471
632
  }
472
633
 
634
+ #cancelRender(requestId: string): void {
635
+ if (this.#status !== "ready") return;
636
+ try {
637
+ this.#proc.send({ type: "cancel", requestId });
638
+ } catch {
639
+ }
640
+ }
641
+
473
642
  #flushQueuedSends(): void {
474
643
  const queue = this.#queuedSends;
475
644
  this.#queuedSends = [];
@@ -0,0 +1,35 @@
1
+ export type CachedRscReplayMessage =
2
+ | { type: "meta"; requestId: string; theme?: string; status?: number }
3
+ | { type: "chunk"; requestId: string; data: Uint8Array }
4
+ | { type: "end"; requestId: string };
5
+
6
+ function yieldToHostEventLoop(): Promise<void> {
7
+ return new Promise((resolve) => setTimeout(resolve, 0));
8
+ }
9
+
10
+ export async function replayCachedRscResult(input: {
11
+ requestId: string;
12
+ chunks: readonly Uint8Array[];
13
+ theme?: string;
14
+ status?: number;
15
+ send: (message: CachedRscReplayMessage) => void;
16
+ isCancelled: () => boolean;
17
+ yieldEveryChunks?: number;
18
+ yieldToHost?: () => Promise<void>;
19
+ }): Promise<boolean> {
20
+ const yieldEveryChunks =
21
+ input.yieldEveryChunks !== undefined && Number.isFinite(input.yieldEveryChunks) && input.yieldEveryChunks > 0
22
+ ? Math.floor(input.yieldEveryChunks)
23
+ : 1;
24
+ const yieldToHost = input.yieldToHost ?? yieldToHostEventLoop;
25
+ if (input.isCancelled()) return false;
26
+ input.send({ type: "meta", requestId: input.requestId, theme: input.theme, status: input.status });
27
+ for (let index = 0; index < input.chunks.length; index += 1) {
28
+ if (input.isCancelled()) return false;
29
+ input.send({ type: "chunk", requestId: input.requestId, data: input.chunks[index] });
30
+ if ((index + 1) % yieldEveryChunks === 0) await yieldToHost();
31
+ }
32
+ if (input.isCancelled()) return false;
33
+ input.send({ type: "end", requestId: input.requestId });
34
+ return true;
35
+ }