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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.2.12",
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
  }
@@ -13,6 +13,7 @@ import type { ClientManifest } from "./artifact";
13
13
  import { ProcessMetricsCollector } from "./processMetricsCollector";
14
14
  import { RouteElementComposer } from "./routeElementComposer";
15
15
  import { type PagesContext, RouteTreeBuilder } from "./routeTreeBuilder";
16
+ import { replayCachedRscResult } from "./rscWorkerReplay";
16
17
  import { createSystemPageDocument, getSystemPageHomeHref } from "./systemPages";
17
18
 
18
19
  interface InitMsg {
@@ -32,6 +33,10 @@ interface RenderMsg {
32
33
  headers?: Record<string, string>;
33
34
  clientManifest?: ClientManifest;
34
35
  }
36
+ interface CancelMsg {
37
+ type: "cancel";
38
+ requestId: string;
39
+ }
35
40
  interface ReloadMsg {
36
41
  type: "reload";
37
42
  clientManifest: ClientManifest;
@@ -44,11 +49,19 @@ interface UpdateCssAssetsMsg {
44
49
  type: "updateCssAssets";
45
50
  cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
46
51
  }
47
- type InMsg = InitMsg | RenderMsg | ReloadMsg | UpdateCssAssetsMsg;
52
+ type InMsg = InitMsg | RenderMsg | CancelMsg | ReloadMsg | UpdateCssAssetsMsg;
48
53
  type RenderControl =
49
54
  | { type: "redirect"; location: string; method: "replace" | "push"; status: RedirectStatus }
50
55
  | { type: "not-found" }
51
56
  | { type: "error"; error: unknown };
57
+ interface FlightRenderResult {
58
+ chunks: Uint8Array[];
59
+ bytes: number;
60
+ chunksCount: number;
61
+ control: RenderControl | null;
62
+ lateControlSent: boolean;
63
+ cancelled: boolean;
64
+ }
52
65
 
53
66
  interface RscRendererStats {
54
67
  renderCount: number;
@@ -128,6 +141,8 @@ class RscRenderer {
128
141
  };
129
142
  readonly #routeStats = new Map<string, RouteRenderStats>();
130
143
  readonly #resultCache = new Map<string, CachedRscResult>();
144
+ readonly #activeRenderReaders = new Map<string, ReadableStreamDefaultReader<Uint8Array>>();
145
+ readonly #cancelledRenderRequests = new Set<string>();
131
146
  #resultCacheHits = 0;
132
147
  #resultCacheMisses = 0;
133
148
  #resultCacheBypass = 0;
@@ -158,6 +173,10 @@ class RscRenderer {
158
173
  this.#logger.verbose(`received render requestId=${msg.requestId} url=${msg.url} method=${msg.method ?? "GET"}`);
159
174
  void this.#handleRender(msg);
160
175
  return;
176
+ case "cancel":
177
+ this.#logger.verbose(`received cancel requestId=${msg.requestId}`);
178
+ this.#handleCancel(msg.requestId);
179
+ return;
161
180
  case "reload":
162
181
  this.#logger.verbose(`received reload buildId=${msg.buildId}`);
163
182
  void this.#handleReload(msg);
@@ -169,6 +188,14 @@ class RscRenderer {
169
188
  }
170
189
  }
171
190
 
191
+ #handleCancel(requestId: string): void {
192
+ this.#cancelledRenderRequests.add(requestId);
193
+ const reader = this.#activeRenderReaders.get(requestId);
194
+ if (!reader) return;
195
+ void reader.cancel().catch(() => {
196
+ });
197
+ }
198
+
172
199
  async #handleInit(msg: InitMsg): Promise<void> {
173
200
  const startedAt = Date.now();
174
201
  try {
@@ -300,27 +327,42 @@ class RscRenderer {
300
327
  this.#stats.totalFlightBytes += cached.bytes;
301
328
  this.#stats.totalFlightChunks += cached.chunksCount;
302
329
  this.#recordRouteStats(routeId, cached.bytes, this.#stats.lastRenderDurationMs);
303
- this.#send({ type: "meta", requestId, theme: cached.theme });
304
- for (const chunk of cached.chunks) this.#send({ type: "chunk", requestId, data: chunk });
305
- this.#send({ type: "end", requestId });
330
+ await replayCachedRscResult({
331
+ requestId,
332
+ chunks: cached.chunks,
333
+ theme: cached.theme,
334
+ send: (message) => this.#send(message),
335
+ isCancelled: () => this.#cancelledRenderRequests.has(requestId),
336
+ });
306
337
  return;
307
338
  }
308
339
  const theme = cookies().get("theme")?.value;
309
- const element = match ? await this.#renderMatched(urlObj, match, theme) : await this.#renderNotFound(urlObj);
340
+ const searchParams = RouteTreeBuilder.parseSearchParams(urlObj.search);
341
+ let element: ReactNode;
342
+ if (match) element = await this.#renderMatched(urlObj, match, theme, searchParams);
343
+ else element = await this.#renderNotFound(urlObj);
310
344
  this.#logger.verbose(`render[${requestId}] starting Flight stream`);
311
- const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest);
345
+ const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest, {
346
+ requestId,
347
+ collectChunks: cacheKey !== null,
348
+ status: match ? undefined : 404,
349
+ });
350
+ if (result.cancelled) return;
312
351
  const control = result.control;
313
352
  if (control) {
314
353
  this.#stats.lastRenderKind = control.type;
354
+ if (result.lateControlSent) {
355
+ this.#logger.verbose(`render[${requestId}] late ${control.type} delivered after stream start`);
356
+ return;
357
+ }
315
358
  if (!match && control.type === "error") {
316
359
  const systemResult = await this.#renderFlightElement(
317
360
  this.#renderSystemNotFound(urlObj),
318
361
  msg.clientManifest ?? this.#clientManifest,
362
+ { requestId, status: 404 },
319
363
  );
364
+ if (systemResult.cancelled) return;
320
365
  if (!systemResult.control) {
321
- this.#send({ type: "meta", requestId, theme: getRequestTheme(), status: 404 });
322
- for (const chunk of systemResult.chunks) this.#send({ type: "chunk", requestId, data: chunk });
323
- this.#send({ type: "end", requestId });
324
366
  return;
325
367
  }
326
368
  }
@@ -332,7 +374,7 @@ class RscRenderer {
332
374
  kind: control.type,
333
375
  route: match.pathRoute,
334
376
  params: match.params,
335
- searchParams: RouteTreeBuilder.parseSearchParams(urlObj.search),
377
+ searchParams,
336
378
  pathname: urlObj.pathname,
337
379
  url: urlObj,
338
380
  error: control.type === "error" ? control.error : undefined,
@@ -345,9 +387,9 @@ class RscRenderer {
345
387
  return;
346
388
  }
347
389
  this.#stats.lastFlightBytes = result.bytes;
348
- this.#stats.lastFlightChunks = result.chunks.length;
390
+ this.#stats.lastFlightChunks = result.chunksCount;
349
391
  this.#stats.totalFlightBytes += result.bytes;
350
- this.#stats.totalFlightChunks += result.chunks.length;
392
+ this.#stats.totalFlightChunks += result.chunksCount;
351
393
  this.#stats.lastRenderDurationMs = Date.now() - startedAt;
352
394
  const afterLoadedKeys = RouteTreeBuilder.getCacheStats().loadedModuleKeys;
353
395
  this.#stats.lastRenderLoadedModules = afterLoadedKeys.filter((key) => !beforeLoadedKeys.includes(key));
@@ -358,17 +400,12 @@ class RscRenderer {
358
400
  this.#setCachedResult(cacheKey, {
359
401
  chunks: result.chunks,
360
402
  bytes: result.bytes,
361
- chunksCount: result.chunks.length,
403
+ chunksCount: result.chunksCount,
362
404
  theme: responseTheme,
363
405
  });
364
- this.#send({ type: "meta", requestId, theme: responseTheme, status: match ? undefined : 404 });
365
- for (const chunk of result.chunks) {
366
- this.#send({ type: "chunk", requestId, data: chunk });
367
- }
368
406
  this.#logger.verbose(
369
- `render[${requestId}] done chunks=${result.chunks.length} bytes=${result.bytes} in ${Date.now() - startedAt}ms`,
407
+ `render[${requestId}] done chunks=${result.chunksCount} bytes=${result.bytes} in ${Date.now() - startedAt}ms`,
370
408
  );
371
- this.#send({ type: "end", requestId });
372
409
  });
373
410
  } catch (error) {
374
411
  if (isAkanRedirectError(error)) {
@@ -435,6 +472,8 @@ class RscRenderer {
435
472
  message: error instanceof Error ? error.message : String(error),
436
473
  });
437
474
  } finally {
475
+ this.#activeRenderReaders.delete(requestId);
476
+ this.#cancelledRenderRequests.delete(requestId);
438
477
  this.#stats.inFlightRenderCount = Math.max(0, this.#stats.inFlightRenderCount - 1);
439
478
  }
440
479
  }
@@ -484,7 +523,12 @@ class RscRenderer {
484
523
  async #renderFlightElement(
485
524
  element: ReactNode,
486
525
  clientManifest: ClientManifest,
487
- ): Promise<{ chunks: Uint8Array[]; bytes: number; control: RenderControl | null }> {
526
+ options: {
527
+ requestId?: string;
528
+ collectChunks?: boolean;
529
+ status?: number;
530
+ } = {},
531
+ ): Promise<FlightRenderResult> {
488
532
  const controlRef: { current: RenderControl | null } = { current: null };
489
533
  const stream = await renderToReadableStream(element, clientManifest, {
490
534
  onError: (error) => {
@@ -506,20 +550,77 @@ class RscRenderer {
506
550
  },
507
551
  });
508
552
  const reader = stream.getReader();
553
+ if (options.requestId) this.#activeRenderReaders.set(options.requestId, reader);
509
554
  let bytes = 0;
555
+ let chunksCount = 0;
556
+ let sentMeta = false;
557
+ let sentChunk = false;
558
+ let lateControlSent = false;
510
559
  const chunks: Uint8Array[] = [];
511
- while (true) {
512
- const { value, done } = await reader.read();
513
- if (done) break;
514
- if (controlRef.current) {
515
- await reader.cancel();
516
- break;
560
+ const sendMeta = () => {
561
+ if (!options.requestId || sentMeta) return;
562
+ sentMeta = true;
563
+ this.#send({ type: "meta", requestId: options.requestId, theme: getRequestTheme(), status: options.status });
564
+ };
565
+ const sendLateRedirect = () => {
566
+ if (!options.requestId || lateControlSent || controlRef.current?.type !== "redirect") return;
567
+
568
+ lateControlSent = true;
569
+ this.#send({
570
+ type: "late-redirect",
571
+ requestId: options.requestId,
572
+ location: controlRef.current.location,
573
+ method: controlRef.current.method,
574
+ status: controlRef.current.status,
575
+ });
576
+ };
577
+ try {
578
+ while (true) {
579
+ if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
580
+ await reader.cancel();
581
+ return { chunks, bytes, chunksCount, control: null, lateControlSent, cancelled: true };
582
+ }
583
+ const { value, done } = await reader.read();
584
+ if (controlRef.current && !sentChunk) {
585
+ await reader.cancel();
586
+ return { chunks, bytes, chunksCount, control: controlRef.current, lateControlSent, cancelled: false };
587
+ }
588
+ if (controlRef.current && sentChunk) sendLateRedirect();
589
+ if (done) break;
590
+ const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
591
+ bytes += chunk.byteLength;
592
+ chunksCount += 1;
593
+ if (options.collectChunks) chunks.push(chunk);
594
+ if (options.requestId) {
595
+ sendMeta();
596
+ this.#send({ type: "chunk", requestId: options.requestId, data: chunk });
597
+ sentChunk = true;
598
+ }
599
+ }
600
+ } catch (error) {
601
+ if (options.requestId && this.#cancelledRenderRequests.has(options.requestId)) {
602
+ return { chunks, bytes, chunksCount, control: null, lateControlSent, cancelled: true };
517
603
  }
518
- const chunk = value instanceof Uint8Array ? value : new Uint8Array(value as ArrayBufferLike);
519
- bytes += chunk.byteLength;
520
- chunks.push(chunk);
604
+ throw error;
605
+ } finally {
606
+ if (options.requestId) this.#activeRenderReaders.delete(options.requestId);
607
+ reader.releaseLock();
608
+ }
609
+ if (controlRef.current && sentChunk) sendLateRedirect();
610
+ if (controlRef.current && !sentChunk)
611
+ return { chunks, bytes, chunksCount, control: controlRef.current, lateControlSent, cancelled: false };
612
+ if (options.requestId) {
613
+ sendMeta();
614
+ this.#send({ type: "end", requestId: options.requestId });
521
615
  }
522
- return { chunks, bytes, control: controlRef.current };
616
+ return {
617
+ chunks,
618
+ bytes,
619
+ chunksCount,
620
+ control: lateControlSent ? controlRef.current : null,
621
+ lateControlSent,
622
+ cancelled: false,
623
+ };
523
624
  }
524
625
 
525
626
  async #trySendFallbackRender({
@@ -555,15 +656,16 @@ class RscRenderer {
555
656
  digest: kind === "error" ? "AKAN_RENDER_ERROR" : undefined,
556
657
  });
557
658
  if (!element) return false;
558
- const result = await this.#renderFlightElement(element, clientManifest);
659
+ const result = await this.#renderFlightElement(element, clientManifest, {
660
+ requestId,
661
+ status: kind === "not-found" ? 404 : 500,
662
+ });
663
+ if (result.cancelled) return true;
559
664
  if (result.control) return false;
560
- this.#send({ type: "meta", requestId, theme: getRequestTheme(), status: kind === "not-found" ? 404 : 500 });
561
- for (const chunk of result.chunks) this.#send({ type: "chunk", requestId, data: chunk });
562
- this.#send({ type: "end", requestId });
563
665
  this.#stats.lastFlightBytes = result.bytes;
564
- this.#stats.lastFlightChunks = result.chunks.length;
666
+ this.#stats.lastFlightChunks = result.chunksCount;
565
667
  this.#stats.totalFlightBytes += result.bytes;
566
- this.#stats.totalFlightChunks += result.chunks.length;
668
+ this.#stats.totalFlightChunks += result.chunksCount;
567
669
  return true;
568
670
  } catch (fallbackError) {
569
671
  this.#logger.error(
@@ -735,8 +837,8 @@ class RscRenderer {
735
837
  url: URL,
736
838
  match: { pathRoute: PathRoute; params: Record<string, string> },
737
839
  theme?: string,
840
+ searchParams = RouteTreeBuilder.parseSearchParams(url.search),
738
841
  ): Promise<ReactNode> {
739
- const searchParams = RouteTreeBuilder.parseSearchParams(url.search);
740
842
  this.#logger.verbose(
741
843
  `composing route element pathname=${url.pathname} search=${url.search || "(none)"} params=${JSON.stringify(match.params)}`,
742
844
  );
@@ -745,7 +847,11 @@ class RscRenderer {
745
847
  params: match.params,
746
848
  searchParams,
747
849
  });
748
- const body = RouteElementComposer.compose({ pathRoute: match.pathRoute, params: match.params, searchParams });
850
+ const body = RouteElementComposer.compose({
851
+ pathRoute: match.pathRoute,
852
+ params: match.params,
853
+ searchParams,
854
+ });
749
855
  return (
750
856
  <html
751
857
  lang={match.params.lang ?? this.#i18n.defaultLocale}
@@ -895,4 +1001,4 @@ class RscRenderer {
895
1001
  }
896
1002
  }
897
1003
 
898
- new RscRenderer().start();
1004
+ if (import.meta.main) new RscRenderer().start();