akanjs 3.0.0-alpha.73 → 3.0.0-alpha.74
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/local/apps/serverLifecycle/serverLifecycle-local.db-shm +0 -0
- package/local/apps/serverLifecycle/serverLifecycle-local_solid.db-shm +0 -0
- package/package.json +1 -1
- package/server/akanApp.ts +44 -0
- package/server/lifecycle/shutdownManager.ts +16 -1
- package/server/rscWorker.tsx +27 -0
- package/server/ssrFromRscRenderer.tsx +50 -0
- package/server/webRouter.ts +6 -0
- package/types/server/lifecycle/shutdownManager.d.ts +2 -1
- package/types/server/ssrFromRscRenderer.d.ts +14 -0
- package/types/ui/Loading/index_.d.ts +13 -0
- package/ui/Loading/index.ts +15 -0
- package/ui/Loading/index_.tsx +22 -0
- package/ui/Loading/index.tsx +0 -22
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -832,6 +832,13 @@ export class AkanApp {
|
|
|
832
832
|
this.#scheduleChildRestart(child, child.proc, "upstream-open-failed");
|
|
833
833
|
return AkanApp.#unavailableResponse(req, "Federation child upstream is unreachable; restarting");
|
|
834
834
|
}
|
|
835
|
+
if (req.signal.aborted) return AkanApp.#clientClosedResponse();
|
|
836
|
+
if (AkanApp.#isUpstreamMidFlightClose(error)) {
|
|
837
|
+
this.logger.warn(
|
|
838
|
+
`Child ${child.idx}/${child.role} closed the connection mid-request (${req.method} ${new URL(req.url).pathname})`,
|
|
839
|
+
);
|
|
840
|
+
return AkanApp.#badGatewayResponse(req);
|
|
841
|
+
}
|
|
835
842
|
throw error;
|
|
836
843
|
} finally {
|
|
837
844
|
child.metrics.activeRequests = Math.max(0, (child.metrics.activeRequests ?? 1) - 1);
|
|
@@ -845,6 +852,43 @@ export class AkanApp {
|
|
|
845
852
|
return candidate.code === "FailedToOpenSocket" || String(candidate.message ?? "").includes("FailedToOpenSocket");
|
|
846
853
|
}
|
|
847
854
|
|
|
855
|
+
/**
|
|
856
|
+
* The socket opened and then died under us: the child crashed, or was recycled, while its response was still
|
|
857
|
+
* in flight. Distinct from `FailedToOpenSocket` — that one means the child was already gone when we dialled,
|
|
858
|
+
* and only that one implies the socket needs a restart, which the child's own exit handler schedules anyway.
|
|
859
|
+
* Rethrowing this instead reaches Bun as an unhandled route error, which answers 500 and prints the raw
|
|
860
|
+
* `TypeError` against whatever URL happened to be in flight — usually a browser background probe, which is
|
|
861
|
+
* how a dead child gets misread as a bug in the probed route.
|
|
862
|
+
*/
|
|
863
|
+
static #isUpstreamMidFlightClose(error: unknown): boolean {
|
|
864
|
+
if (!error || typeof error !== "object") return false;
|
|
865
|
+
const candidate = error as { code?: unknown; message?: unknown };
|
|
866
|
+
if (candidate.code === "ECONNRESET" || candidate.code === "ConnectionClosed") return true;
|
|
867
|
+
return String(candidate.message ?? "").includes("connection was closed");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
static #clientClosedResponse(): Response {
|
|
871
|
+
return new Response(null, { status: 499, headers: { "cache-control": "no-store" } });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
static #badGatewayResponse(req: Request): Response {
|
|
875
|
+
const detail = "The replica closed the connection before it finished answering.";
|
|
876
|
+
if (req.headers.get("accept")?.includes("text/html")) {
|
|
877
|
+
return new Response(
|
|
878
|
+
AkanApp.#statusPageHtml({
|
|
879
|
+
heading: "Backend dropped the connection",
|
|
880
|
+
detail,
|
|
881
|
+
note: "The replica is restarting — this page reloads itself as soon as it answers.",
|
|
882
|
+
}),
|
|
883
|
+
{ status: 502, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
return new Response(detail, {
|
|
887
|
+
status: 502,
|
|
888
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
848
892
|
/**
|
|
849
893
|
* A replica that is booting or restarting is normally back within a second or two, and a browser
|
|
850
894
|
* handed a 503 stays on that dead page until somebody reloads by hand — so a request waits for the
|
|
@@ -4,7 +4,8 @@ export class ShutdownManager {
|
|
|
4
4
|
/**
|
|
5
5
|
* Registers process-level handlers that drive `onShutdown` on:
|
|
6
6
|
* - SIGTERM / SIGINT (graceful stop → exit(0) on success, exit(1) on error)
|
|
7
|
-
* - uncaughtException
|
|
7
|
+
* - uncaughtException (best-effort stop → exit(1))
|
|
8
|
+
* - unhandledRejection (logged at error, process kept alive; see `#isFatalUnhandledRejection`)
|
|
8
9
|
*
|
|
9
10
|
* Kept separate from `AkanServer` so the server class doesn't have to know about
|
|
10
11
|
* Node-level process events, and so tests can opt out by not calling it.
|
|
@@ -37,6 +38,7 @@ export class ShutdownManager {
|
|
|
37
38
|
|
|
38
39
|
process.on("unhandledRejection", async (reason) => {
|
|
39
40
|
logger.error(`Unhandled rejection: ${ShutdownManager.#formatError(reason)}`);
|
|
41
|
+
if (!ShutdownManager.#isFatalUnhandledRejection()) return;
|
|
40
42
|
try {
|
|
41
43
|
await onShutdown();
|
|
42
44
|
process.exit(1);
|
|
@@ -46,6 +48,19 @@ export class ShutdownManager {
|
|
|
46
48
|
});
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/**
|
|
52
|
+
* A rejected promise nobody awaited is scoped to whatever built it, so it cannot leave the process in the
|
|
53
|
+
* unknown state an `uncaughtException` does — while exiting over one drops every other in-flight request,
|
|
54
|
+
* every websocket, and (under federation) restarts the replica. React's streaming SSR is the case that forced
|
|
55
|
+
* this: it rejects `stream.allReady` for a post-shell render failure and holds no handler of its own, so one
|
|
56
|
+
* page's boundary error used to kill a whole child. Set `AKAN_FATAL_UNHANDLED_REJECTION=1` to restore the
|
|
57
|
+
* exit for a deployment that would rather crash than serve on.
|
|
58
|
+
*/
|
|
59
|
+
static #isFatalUnhandledRejection(): boolean {
|
|
60
|
+
const flag = process.env.AKAN_FATAL_UNHANDLED_REJECTION;
|
|
61
|
+
return flag === "1" || flag === "true";
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
static #formatError(error: unknown): string {
|
|
50
65
|
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
51
66
|
}
|
package/server/rscWorker.tsx
CHANGED
|
@@ -850,6 +850,32 @@ export class RscRenderer {
|
|
|
850
850
|
this.#send({ type: "metrics", metrics });
|
|
851
851
|
}
|
|
852
852
|
|
|
853
|
+
/**
|
|
854
|
+
* An error raised after the first Flight chunk has left the worker can no longer become a status code or a
|
|
855
|
+
* system error page — `sendLateRedirect` is the only control the host can still act on, so the render
|
|
856
|
+
* control is dropped. Logging here is the only record that the request failed at all; without it a page
|
|
857
|
+
* whose boundary died mid-stream is indistinguishable from one that rendered.
|
|
858
|
+
*/
|
|
859
|
+
#reportRenderError(error: unknown, pathname?: string): void {
|
|
860
|
+
const description = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
861
|
+
const scope = pathname ? ` path=${pathname}` : "";
|
|
862
|
+
if (RscRenderer.#isExpectedRequestAbort(error)) {
|
|
863
|
+
this.#logger.debug(`[rsc] render aborted${scope}: ${description}`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
this.#logger.error(`[rsc] render failed${scope}: ${description}`);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A client that navigated away, or a stream the host cancelled: expected, and not the replica's problem. */
|
|
870
|
+
static #isExpectedRequestAbort(error: unknown): boolean {
|
|
871
|
+
if (!(error instanceof Error)) return false;
|
|
872
|
+
return (
|
|
873
|
+
error.name === "AbortError" ||
|
|
874
|
+
error.message === "Connection closed." ||
|
|
875
|
+
error.message.includes("The connection was closed")
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
853
879
|
async #renderFlightElement(
|
|
854
880
|
element: ReactNode,
|
|
855
881
|
clientManifest: ClientManifest,
|
|
@@ -888,6 +914,7 @@ export class RscRenderer {
|
|
|
888
914
|
return error.digest;
|
|
889
915
|
}
|
|
890
916
|
controlRef.current = { type: "error", error };
|
|
917
|
+
this.#reportRenderError(error, options.trace?.pathname);
|
|
891
918
|
return error instanceof Error ? error.message : String(error);
|
|
892
919
|
},
|
|
893
920
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Readable } from "node:stream";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
2
3
|
import { type AkanRequestStore, type AkanTheme, pushRequestFallback, requestStorage } from "akanjs/fetch";
|
|
3
4
|
import type { ReactNode } from "react";
|
|
4
5
|
import { renderToReadableStream } from "react-dom/server.browser";
|
|
@@ -575,6 +576,8 @@ export class SsrFromRscRenderer {
|
|
|
575
576
|
SsrFromRscRenderer.#installWebpackShims();
|
|
576
577
|
}
|
|
577
578
|
|
|
579
|
+
static readonly #logger = new Logger("SsrFromRscRenderer");
|
|
580
|
+
|
|
578
581
|
static getChunkRegistryStats(): SsrChunkRegistryStats {
|
|
579
582
|
return { ...SsrFromRscRenderer.#chunkRegistryStats };
|
|
580
583
|
}
|
|
@@ -599,8 +602,14 @@ export class SsrFromRscRenderer {
|
|
|
599
602
|
const root = await thenable;
|
|
600
603
|
const stream = await renderToReadableStream(root, {
|
|
601
604
|
bootstrapScriptContent: bootstrap,
|
|
605
|
+
onError: (error) => SsrFromRscRenderer.#reportRenderError(error, input),
|
|
602
606
|
});
|
|
603
607
|
if (waitForAllReady) await stream.allReady;
|
|
608
|
+
else {
|
|
609
|
+
SsrFromRscRenderer.holdPostShellErrors(stream, (error) =>
|
|
610
|
+
SsrFromRscRenderer.#reportRenderError(error, input, "post-shell"),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
604
613
|
return stream;
|
|
605
614
|
};
|
|
606
615
|
const requestContext = input.requestStore ?? input.request;
|
|
@@ -657,6 +666,47 @@ export class SsrFromRscRenderer {
|
|
|
657
666
|
};
|
|
658
667
|
}
|
|
659
668
|
|
|
669
|
+
/**
|
|
670
|
+
* React rejects `stream.allReady` for any fatal error raised after the shell flushed — an abort landing
|
|
671
|
+
* mid-flush, a `flushCompletedQueues` invariant — and it attaches a handler to that promise itself only on
|
|
672
|
+
* the shell-error path, because `completeShell` replaces `onShellError` with a noop. Shell-first streaming
|
|
673
|
+
* never reads `allReady`, so an unheld rejection reaches `process.on("unhandledRejection")`, and under
|
|
674
|
+
* federation that took the whole replica down over one request's render.
|
|
675
|
+
*
|
|
676
|
+
* Holding it keeps the failure scoped to the stream that raised it. The response cannot become a 5xx — the
|
|
677
|
+
* shell's headers and markup are already on the wire — so the client gets a truncated document and the stack
|
|
678
|
+
* goes to the log; every other request and socket on the replica survives.
|
|
679
|
+
*/
|
|
680
|
+
static holdPostShellErrors(stream: { allReady: Promise<void> }, report: (error: unknown) => void): void {
|
|
681
|
+
void stream.allReady.catch(report);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
static #reportRenderError(error: unknown, input: SsrFromRscInput, phase = "render"): void {
|
|
685
|
+
const description = SsrFromRscRenderer.#describeError(error);
|
|
686
|
+
if (SsrFromRscRenderer.#isExpectedRequestAbort(error)) {
|
|
687
|
+
SsrFromRscRenderer.#logger.debug(`[SSR] ${phase} aborted: ${description}`);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const path = input.request ? new URL(input.request.url).pathname : "unknown";
|
|
691
|
+
SsrFromRscRenderer.#logger.error(`[SSR] ${phase} failed path=${path}: ${description}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
static #describeError(error: unknown): string {
|
|
695
|
+
if (!(error instanceof Error)) return String(error);
|
|
696
|
+
return error.stack ?? error.message;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** A client that navigated away, or a stream we cancelled ourselves: expected, and not the replica's problem. */
|
|
700
|
+
static #isExpectedRequestAbort(error: unknown): boolean {
|
|
701
|
+
if (!(error instanceof Error)) return false;
|
|
702
|
+
return (
|
|
703
|
+
error.name === "AbortError" ||
|
|
704
|
+
error.name === "AkanRedirectError" ||
|
|
705
|
+
error.message === "Connection closed." ||
|
|
706
|
+
error.message.includes("The connection was closed")
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
660
710
|
static #suppressExpectedLateRedirectError(
|
|
661
711
|
thenable: PromiseLike<ReactNode>,
|
|
662
712
|
lateControl?: Promise<SsrLateRedirect | null>,
|
package/server/webRouter.ts
CHANGED
|
@@ -519,6 +519,12 @@ export class WebRouter {
|
|
|
519
519
|
WebRouter.#deepLinkAssociationResponse(ANDROID_ASSET_LINKS_PATH, this.#artifact, {
|
|
520
520
|
cacheControl: this.#prodMode ? "public, max-age=3600" : "no-store",
|
|
521
521
|
}) ?? new Response("Not Found", { status: 404 }),
|
|
522
|
+
|
|
523
|
+
"/.well-known/*": () =>
|
|
524
|
+
new Response("Not Found", {
|
|
525
|
+
status: 404,
|
|
526
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" },
|
|
527
|
+
}),
|
|
522
528
|
[FIREBASE_MESSAGING_SW_PATH]: async () => {
|
|
523
529
|
this.#requestStats.staticAsset += 1;
|
|
524
530
|
const firebaseConfig = await WebRouter.#resolveFirebaseClientConfig();
|
|
@@ -4,7 +4,8 @@ export declare class ShutdownManager {
|
|
|
4
4
|
/**
|
|
5
5
|
* Registers process-level handlers that drive `onShutdown` on:
|
|
6
6
|
* - SIGTERM / SIGINT (graceful stop → exit(0) on success, exit(1) on error)
|
|
7
|
-
* - uncaughtException
|
|
7
|
+
* - uncaughtException (best-effort stop → exit(1))
|
|
8
|
+
* - unhandledRejection (logged at error, process kept alive; see `#isFatalUnhandledRejection`)
|
|
8
9
|
*
|
|
9
10
|
* Kept separate from `AkanServer` so the server class doesn't have to know about
|
|
10
11
|
* Node-level process events, and so tests can opt out by not calling it.
|
|
@@ -36,4 +36,18 @@ export declare class SsrFromRscRenderer {
|
|
|
36
36
|
#private;
|
|
37
37
|
static getChunkRegistryStats(): SsrChunkRegistryStats;
|
|
38
38
|
render(input: SsrFromRscInput): Promise<ReadableStream<Uint8Array>>;
|
|
39
|
+
/**
|
|
40
|
+
* React rejects `stream.allReady` for any fatal error raised after the shell flushed — an abort landing
|
|
41
|
+
* mid-flush, a `flushCompletedQueues` invariant — and it attaches a handler to that promise itself only on
|
|
42
|
+
* the shell-error path, because `completeShell` replaces `onShellError` with a noop. Shell-first streaming
|
|
43
|
+
* never reads `allReady`, so an unheld rejection reaches `process.on("unhandledRejection")`, and under
|
|
44
|
+
* federation that took the whole replica down over one request's render.
|
|
45
|
+
*
|
|
46
|
+
* Holding it keeps the failure scoped to the stream that raised it. The response cannot become a 5xx — the
|
|
47
|
+
* shell's headers and markup are already on the wire — so the client gets a truncated document and the stack
|
|
48
|
+
* goes to the log; every other request and socket on the replica survives.
|
|
49
|
+
*/
|
|
50
|
+
static holdPostShellErrors(stream: {
|
|
51
|
+
allReady: Promise<void>;
|
|
52
|
+
}, report: (error: unknown) => void): void;
|
|
39
53
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One named export per member, rather than the `Loading` namespace object itself, because the `"use client"`
|
|
3
|
+
* transform replaces a client module's *exports* with `registerClientReference` stubs. A namespace exported
|
|
4
|
+
* from here would reach a server component as one reference for the whole object, and `Loading.Skeleton` on it
|
|
5
|
+
* would read a property the stub does not have — `undefined`, which React reports as an invalid element type.
|
|
6
|
+
* The namespace is assembled in `index.ts`, which carries no directive, so each member crosses on its own.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LoadingArea: import("react").ComponentType<Record<string, never>>;
|
|
9
|
+
export declare const LoadingButton: import("react").ComponentType<import("./Button.d.ts").LoadingProps>;
|
|
10
|
+
export declare const LoadingInput: import("react").ComponentType<import("./Input.d.ts").LoadingProps>;
|
|
11
|
+
export declare const LoadingProgressBar: import("react").ComponentType<import("./ProgressBar.d.ts").ProgressBarProps>;
|
|
12
|
+
export declare const LoadingSkeleton: import("react").ComponentType<import("./Skeleton.d.ts").SkeletonProps>;
|
|
13
|
+
export declare const LoadingSpin: import("react").ComponentType<import("./Spin.d.ts").SpinProps>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { LoadingArea, LoadingButton, LoadingInput, LoadingProgressBar, LoadingSkeleton, LoadingSpin } from "./index_";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loading indicators. Each member is an independent override slot (`LoadingSpin`, `LoadingSkeleton`,
|
|
5
|
+
* `LoadingProgressBar`, `LoadingButton`, `LoadingInput`, `LoadingArea`), resolved from the closest
|
|
6
|
+
* `page/**\/_overrides.tsx` in the route's ancestry, otherwise the shipped default.
|
|
7
|
+
*/
|
|
8
|
+
export const Loading = {
|
|
9
|
+
Area: LoadingArea,
|
|
10
|
+
Button: LoadingButton,
|
|
11
|
+
Input: LoadingInput,
|
|
12
|
+
ProgressBar: LoadingProgressBar,
|
|
13
|
+
Skeleton: LoadingSkeleton,
|
|
14
|
+
Spin: LoadingSpin,
|
|
15
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createOverridable } from "../UiOverride";
|
|
3
|
+
import { Area } from "./Area";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { Input } from "./Input";
|
|
6
|
+
import { ProgressBar } from "./ProgressBar";
|
|
7
|
+
import { Skeleton } from "./Skeleton";
|
|
8
|
+
import { Spin } from "./Spin";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One named export per member, rather than the `Loading` namespace object itself, because the `"use client"`
|
|
12
|
+
* transform replaces a client module's *exports* with `registerClientReference` stubs. A namespace exported
|
|
13
|
+
* from here would reach a server component as one reference for the whole object, and `Loading.Skeleton` on it
|
|
14
|
+
* would read a property the stub does not have — `undefined`, which React reports as an invalid element type.
|
|
15
|
+
* The namespace is assembled in `index.ts`, which carries no directive, so each member crosses on its own.
|
|
16
|
+
*/
|
|
17
|
+
export const LoadingArea = createOverridable("LoadingArea", Area);
|
|
18
|
+
export const LoadingButton = createOverridable("LoadingButton", Button);
|
|
19
|
+
export const LoadingInput = createOverridable("LoadingInput", Input);
|
|
20
|
+
export const LoadingProgressBar = createOverridable("LoadingProgressBar", ProgressBar);
|
|
21
|
+
export const LoadingSkeleton = createOverridable("LoadingSkeleton", Skeleton);
|
|
22
|
+
export const LoadingSpin = createOverridable("LoadingSpin", Spin);
|
package/ui/Loading/index.tsx
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { createOverridable } from "../UiOverride";
|
|
3
|
-
import { Area } from "./Area";
|
|
4
|
-
import { Button } from "./Button";
|
|
5
|
-
import { Input } from "./Input";
|
|
6
|
-
import { ProgressBar } from "./ProgressBar";
|
|
7
|
-
import { Skeleton } from "./Skeleton";
|
|
8
|
-
import { Spin } from "./Spin";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Loading indicators. Each member is an independent override slot (`LoadingSpin`, `LoadingSkeleton`,
|
|
12
|
-
* `LoadingProgressBar`, `LoadingButton`, `LoadingInput`, `LoadingArea`), resolved from the closest
|
|
13
|
-
* `page/**\/_overrides.tsx` in the route's ancestry, otherwise the shipped default.
|
|
14
|
-
*/
|
|
15
|
-
export const Loading = {
|
|
16
|
-
Area: createOverridable("LoadingArea", Area),
|
|
17
|
-
Button: createOverridable("LoadingButton", Button),
|
|
18
|
-
Input: createOverridable("LoadingInput", Input),
|
|
19
|
-
ProgressBar: createOverridable("LoadingProgressBar", ProgressBar),
|
|
20
|
-
Skeleton: createOverridable("LoadingSkeleton", Skeleton),
|
|
21
|
-
Spin: createOverridable("LoadingSpin", Spin),
|
|
22
|
-
};
|