@jskit-ai/kernel 0.1.86 → 0.1.88

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.
@@ -0,0 +1,53 @@
1
+ export type AsyncModuleRecoveryState = {
2
+ attempt: number;
3
+ error: any;
4
+ label: string;
5
+ message: string;
6
+ retry: (() => any) | null;
7
+ stale: boolean;
8
+ visible: boolean;
9
+ };
10
+
11
+ export function createAsyncModuleRecoveryState(options?: {
12
+ label?: string;
13
+ message?: string;
14
+ retry?: (() => any) | null;
15
+ }): AsyncModuleRecoveryState;
16
+
17
+ export function isDynamicImportError(error?: any): boolean;
18
+
19
+ export function dynamicImportErrorMessage(error?: any, options?: {
20
+ label?: string;
21
+ stale?: boolean;
22
+ }): string;
23
+
24
+ export function notifyAsyncModuleLoadError(
25
+ state: AsyncModuleRecoveryState,
26
+ error?: any,
27
+ options?: {
28
+ label?: string;
29
+ message?: string;
30
+ retry?: (() => any) | null;
31
+ stale?: boolean;
32
+ }
33
+ ): AsyncModuleRecoveryState;
34
+
35
+ export function dismissAsyncModuleRecovery(state: AsyncModuleRecoveryState): boolean;
36
+
37
+ export function guardedReloadApp(options?: {
38
+ browserWindow?: any;
39
+ fetchFn?: ((input: string, init?: Record<string, any>) => Promise<any>) | null;
40
+ state?: AsyncModuleRecoveryState | null;
41
+ label?: string;
42
+ message?: string;
43
+ }): Promise<boolean>;
44
+
45
+ export function installAsyncModuleRecoveryHandlers(options?: {
46
+ router?: any;
47
+ state: AsyncModuleRecoveryState;
48
+ label?: string;
49
+ onNotify?: (state: AsyncModuleRecoveryState) => void;
50
+ windowObject?: any;
51
+ }): Readonly<{
52
+ dispose: () => void;
53
+ }>;
@@ -0,0 +1,196 @@
1
+ const DYNAMIC_IMPORT_ERROR_PATTERNS = Object.freeze([
2
+ /ChunkLoadError/iu,
3
+ /Failed to fetch dynamically imported module/iu,
4
+ /Importing a module script failed/iu,
5
+ /error loading dynamically imported module/iu,
6
+ /Loading chunk .+ failed/iu,
7
+ /Unable to preload CSS/iu
8
+ ]);
9
+
10
+ function isRecord(value) {
11
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
12
+ }
13
+
14
+ function errorText(error = null) {
15
+ return String(error?.message || error || "").trim();
16
+ }
17
+
18
+ function isDynamicImportError(error = null) {
19
+ const text = errorText(error);
20
+ return Boolean(text && DYNAMIC_IMPORT_ERROR_PATTERNS.some((pattern) => pattern.test(text)));
21
+ }
22
+
23
+ function dynamicImportErrorMessage(error = null, {
24
+ label = "App module",
25
+ stale = isDynamicImportError(error)
26
+ } = {}) {
27
+ const moduleLabel = String(label || "App module").trim();
28
+ if (stale) {
29
+ return `${moduleLabel} did not download. The app may have been updated, or the network request failed.`;
30
+ }
31
+ return `${moduleLabel} could not load.`;
32
+ }
33
+
34
+ function createAsyncModuleRecoveryState({
35
+ label = "App module",
36
+ message = "",
37
+ retry = null
38
+ } = {}) {
39
+ return {
40
+ attempt: 0,
41
+ error: null,
42
+ label: String(label || "App module").trim(),
43
+ message: String(message || "").trim(),
44
+ retry: typeof retry === "function" ? retry : null,
45
+ stale: false,
46
+ visible: false
47
+ };
48
+ }
49
+
50
+ function notifyAsyncModuleLoadError(state, error = null, {
51
+ label = "App module",
52
+ message = "",
53
+ retry = null,
54
+ stale = isDynamicImportError(error)
55
+ } = {}) {
56
+ if (!isRecord(state)) {
57
+ throw new TypeError("notifyAsyncModuleLoadError requires a mutable recovery state object.");
58
+ }
59
+
60
+ const normalizedLabel = String(label || "App module").trim();
61
+ state.attempt = Number(state.attempt || 0) + 1;
62
+ state.error = error || null;
63
+ state.label = normalizedLabel;
64
+ state.message = String(message || "").trim() ||
65
+ dynamicImportErrorMessage(error, {
66
+ label: normalizedLabel,
67
+ stale
68
+ });
69
+ state.retry = typeof retry === "function" ? retry : null;
70
+ state.stale = Boolean(stale);
71
+ state.visible = true;
72
+ return state;
73
+ }
74
+
75
+ function dismissAsyncModuleRecovery(state) {
76
+ if (!isRecord(state)) {
77
+ return false;
78
+ }
79
+
80
+ state.visible = false;
81
+ return true;
82
+ }
83
+
84
+ async function guardedReloadApp({
85
+ browserWindow = typeof window !== "undefined" ? window : null,
86
+ fetchFn = typeof fetch === "function" ? fetch : null,
87
+ state = null,
88
+ label = "App",
89
+ message = "The app cannot reload because the app server is not reachable. Restart the server, then click Retry or Reload."
90
+ } = {}) {
91
+ if (!browserWindow?.location) {
92
+ return false;
93
+ }
94
+ if (typeof fetchFn !== "function") {
95
+ browserWindow.location.reload();
96
+ return true;
97
+ }
98
+
99
+ try {
100
+ const response = await fetchFn(String(browserWindow.location.href || "/"), {
101
+ cache: "no-store",
102
+ credentials: "same-origin",
103
+ headers: {
104
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
105
+ },
106
+ method: "GET"
107
+ });
108
+ if (!response?.ok) {
109
+ throw new Error(`Reload check failed with HTTP ${response?.status || 0}.`);
110
+ }
111
+ browserWindow.location.reload();
112
+ return true;
113
+ } catch (error) {
114
+ if (state) {
115
+ notifyAsyncModuleLoadError(state, error, {
116
+ label,
117
+ message,
118
+ retry: state.retry,
119
+ stale: false
120
+ });
121
+ }
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function installAsyncModuleRecoveryHandlers({
127
+ router = null,
128
+ state,
129
+ label = "App module",
130
+ onNotify = null,
131
+ windowObject = typeof window !== "undefined" ? window : null
132
+ } = {}) {
133
+ if (!isRecord(state)) {
134
+ throw new TypeError("installAsyncModuleRecoveryHandlers requires a mutable recovery state object.");
135
+ }
136
+
137
+ const disposers = [];
138
+ const notify = typeof onNotify === "function" ? onNotify : () => null;
139
+
140
+ if (router && typeof router.onError === "function") {
141
+ const removeRouterHandler = router.onError((error, to = {}) => {
142
+ if (!isDynamicImportError(error)) {
143
+ return;
144
+ }
145
+
146
+ const fullPath = String(to?.fullPath || "");
147
+ notifyAsyncModuleLoadError(state, error, {
148
+ label: "Page",
149
+ retry: fullPath && typeof router.replace === "function"
150
+ ? () => router.replace(fullPath)
151
+ : null,
152
+ stale: true
153
+ });
154
+ notify(state);
155
+ });
156
+ if (typeof removeRouterHandler === "function") {
157
+ disposers.push(removeRouterHandler);
158
+ }
159
+ }
160
+
161
+ if (windowObject && typeof windowObject.addEventListener === "function") {
162
+ const handler = (event) => {
163
+ const error = event?.reason;
164
+ if (!isDynamicImportError(error)) {
165
+ return;
166
+ }
167
+ notifyAsyncModuleLoadError(state, error, {
168
+ label,
169
+ stale: true
170
+ });
171
+ notify(state);
172
+ };
173
+ windowObject.addEventListener("unhandledrejection", handler);
174
+ disposers.push(() => {
175
+ windowObject.removeEventListener?.("unhandledrejection", handler);
176
+ });
177
+ }
178
+
179
+ return Object.freeze({
180
+ dispose() {
181
+ for (const dispose of disposers.splice(0)) {
182
+ dispose();
183
+ }
184
+ }
185
+ });
186
+ }
187
+
188
+ export {
189
+ createAsyncModuleRecoveryState,
190
+ dismissAsyncModuleRecovery,
191
+ dynamicImportErrorMessage,
192
+ guardedReloadApp,
193
+ installAsyncModuleRecoveryHandlers,
194
+ isDynamicImportError,
195
+ notifyAsyncModuleLoadError
196
+ };
@@ -0,0 +1,185 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ createAsyncModuleRecoveryState,
5
+ dismissAsyncModuleRecovery,
6
+ dynamicImportErrorMessage,
7
+ guardedReloadApp,
8
+ installAsyncModuleRecoveryHandlers,
9
+ isDynamicImportError,
10
+ notifyAsyncModuleLoadError
11
+ } from "./asyncModuleRecovery.js";
12
+
13
+ function createWindowDouble() {
14
+ const listeners = new Map();
15
+ return {
16
+ listeners,
17
+ addEventListener(type, handler) {
18
+ listeners.set(type, handler);
19
+ },
20
+ removeEventListener(type, handler) {
21
+ if (listeners.get(type) === handler) {
22
+ listeners.delete(type);
23
+ }
24
+ }
25
+ };
26
+ }
27
+
28
+ test("async module recovery recognizes dynamic import failures only", () => {
29
+ assert.equal(
30
+ isDynamicImportError(new Error("Failed to fetch dynamically imported module: /assets/page.js")),
31
+ true
32
+ );
33
+ assert.equal(isDynamicImportError(new Error("Loading chunk dashboard failed")), true);
34
+ assert.equal(isDynamicImportError(new Error("Request failed.")), false);
35
+ });
36
+
37
+ test("async module recovery mutates state with an actionable retry", () => {
38
+ const retry = () => "retried";
39
+ const state = createAsyncModuleRecoveryState();
40
+ const error = new Error("Failed to fetch dynamically imported module: /assets/tool.js");
41
+
42
+ notifyAsyncModuleLoadError(state, error, {
43
+ label: "Tool",
44
+ retry
45
+ });
46
+
47
+ assert.equal(state.visible, true);
48
+ assert.equal(state.attempt, 1);
49
+ assert.equal(state.label, "Tool");
50
+ assert.equal(state.message, "Tool did not download. The app may have been updated, or the network request failed.");
51
+ assert.equal(state.retry, retry);
52
+ assert.equal(state.stale, true);
53
+
54
+ assert.equal(dismissAsyncModuleRecovery(state), true);
55
+ assert.equal(state.visible, false);
56
+ });
57
+
58
+ test("async module recovery message stays generic for non-stale failures", () => {
59
+ assert.equal(
60
+ dynamicImportErrorMessage(new Error("boom"), {
61
+ label: "Widget",
62
+ stale: false
63
+ }),
64
+ "Widget could not load."
65
+ );
66
+ });
67
+
68
+ test("async module recovery installs router and unhandled rejection handlers", async () => {
69
+ let routerHandler = null;
70
+ const replaced = [];
71
+ const notifications = [];
72
+ const windowObject = createWindowDouble();
73
+ const state = createAsyncModuleRecoveryState();
74
+ const disposable = installAsyncModuleRecoveryHandlers({
75
+ state,
76
+ label: "Runtime module",
77
+ onNotify(nextState) {
78
+ notifications.push({
79
+ attempt: nextState.attempt,
80
+ label: nextState.label
81
+ });
82
+ },
83
+ windowObject,
84
+ router: {
85
+ onError(handler) {
86
+ routerHandler = handler;
87
+ return () => {
88
+ routerHandler = null;
89
+ };
90
+ },
91
+ replace(fullPath) {
92
+ replaced.push(fullPath);
93
+ }
94
+ }
95
+ });
96
+
97
+ routerHandler(new Error("Failed to fetch dynamically imported module: /assets/page.js"), {
98
+ fullPath: "/app/home"
99
+ });
100
+
101
+ assert.equal(state.label, "Page");
102
+ assert.deepEqual(notifications[0], {
103
+ attempt: 1,
104
+ label: "Page"
105
+ });
106
+ assert.equal(typeof state.retry, "function");
107
+ await state.retry();
108
+ assert.deepEqual(replaced, ["/app/home"]);
109
+
110
+ windowObject.listeners.get("unhandledrejection")({
111
+ reason: new Error("Importing a module script failed.")
112
+ });
113
+ assert.equal(state.label, "Runtime module");
114
+ assert.deepEqual(notifications[1], {
115
+ attempt: 2,
116
+ label: "Runtime module"
117
+ });
118
+
119
+ disposable.dispose();
120
+ assert.equal(routerHandler, null);
121
+ assert.equal(windowObject.listeners.has("unhandledrejection"), false);
122
+ });
123
+
124
+ test("guarded reload navigates only after the current document is reachable", async () => {
125
+ const reloadCalls = [];
126
+ const fetchCalls = [];
127
+ const result = await guardedReloadApp({
128
+ browserWindow: {
129
+ location: {
130
+ href: "https://example.test/app",
131
+ reload() {
132
+ reloadCalls.push("reload");
133
+ }
134
+ }
135
+ },
136
+ async fetchFn(input, init) {
137
+ fetchCalls.push({ input, init });
138
+ return {
139
+ ok: true,
140
+ status: 200
141
+ };
142
+ }
143
+ });
144
+
145
+ assert.equal(result, true);
146
+ assert.deepEqual(reloadCalls, ["reload"]);
147
+ assert.equal(fetchCalls[0].input, "https://example.test/app");
148
+ assert.equal(fetchCalls[0].init.cache, "no-store");
149
+ assert.equal(fetchCalls[0].init.credentials, "same-origin");
150
+ });
151
+
152
+ test("guarded reload keeps the page alive when the server is unreachable", async () => {
153
+ const state = createAsyncModuleRecoveryState();
154
+ const retry = () => null;
155
+ notifyAsyncModuleLoadError(state, new Error("Failed to fetch dynamically imported module: /assets/tool.js"), {
156
+ label: "Tool",
157
+ retry
158
+ });
159
+
160
+ const reloadCalls = [];
161
+ const result = await guardedReloadApp({
162
+ state,
163
+ label: "App",
164
+ message: "App cannot reload yet.",
165
+ browserWindow: {
166
+ location: {
167
+ href: "https://example.test/app",
168
+ reload() {
169
+ reloadCalls.push("reload");
170
+ }
171
+ }
172
+ },
173
+ async fetchFn() {
174
+ throw new TypeError("Failed to fetch");
175
+ }
176
+ });
177
+
178
+ assert.equal(result, false);
179
+ assert.deepEqual(reloadCalls, []);
180
+ assert.equal(state.visible, true);
181
+ assert.equal(state.label, "App");
182
+ assert.equal(state.message, "App cannot reload yet.");
183
+ assert.equal(state.retry, retry);
184
+ assert.equal(state.stale, false);
185
+ });
package/client/index.d.ts CHANGED
@@ -6,6 +6,60 @@ export type ClientLogger = {
6
6
  isDebugEnabled?: boolean;
7
7
  };
8
8
 
9
+ export type AsyncModuleRecoveryState = {
10
+ attempt: number;
11
+ error: any;
12
+ label: string;
13
+ message: string;
14
+ retry: (() => any) | null;
15
+ stale: boolean;
16
+ visible: boolean;
17
+ };
18
+
19
+ export function createAsyncModuleRecoveryState(options?: {
20
+ label?: string;
21
+ message?: string;
22
+ retry?: (() => any) | null;
23
+ }): AsyncModuleRecoveryState;
24
+
25
+ export function isDynamicImportError(error?: any): boolean;
26
+
27
+ export function dynamicImportErrorMessage(error?: any, options?: {
28
+ label?: string;
29
+ stale?: boolean;
30
+ }): string;
31
+
32
+ export function notifyAsyncModuleLoadError(
33
+ state: AsyncModuleRecoveryState,
34
+ error?: any,
35
+ options?: {
36
+ label?: string;
37
+ message?: string;
38
+ retry?: (() => any) | null;
39
+ stale?: boolean;
40
+ }
41
+ ): AsyncModuleRecoveryState;
42
+
43
+ export function dismissAsyncModuleRecovery(state: AsyncModuleRecoveryState): boolean;
44
+
45
+ export function guardedReloadApp(options?: {
46
+ browserWindow?: any;
47
+ fetchFn?: ((input: string, init?: Record<string, any>) => Promise<any>) | null;
48
+ state?: AsyncModuleRecoveryState | null;
49
+ label?: string;
50
+ message?: string;
51
+ }): Promise<boolean>;
52
+
53
+ export function installAsyncModuleRecoveryHandlers(options?: {
54
+ router?: any;
55
+ state: AsyncModuleRecoveryState;
56
+ label?: string;
57
+ onNotify?: (state: AsyncModuleRecoveryState) => void;
58
+ windowObject?: any;
59
+ }): Readonly<{
60
+ dispose: () => void;
61
+ }>;
62
+
9
63
  export function getClientAppConfig(): Readonly<Record<string, any>>;
10
64
  export function resolveMobileConfig(appConfig?: Record<string, any>): Readonly<Record<string, any>>;
11
65
  export function resolveClientAssetMode(appConfig?: Record<string, any>): string;
package/client/index.js CHANGED
@@ -1,4 +1,13 @@
1
1
  export { getClientAppConfig, resolveMobileConfig, resolveClientAssetMode } from "./appConfig.js";
2
+ export {
3
+ createAsyncModuleRecoveryState,
4
+ dismissAsyncModuleRecovery,
5
+ dynamicImportErrorMessage,
6
+ guardedReloadApp,
7
+ installAsyncModuleRecoveryHandlers,
8
+ isDynamicImportError,
9
+ notifyAsyncModuleLoadError
10
+ } from "./asyncModuleRecovery.js";
2
11
  export { normalizeIncomingAppUrl, registerMobileLaunchRouting } from "./mobileLaunchRouting.js";
3
12
  export { resolveClientBootstrapDebugEnabled, createSurfaceShellRouter as createShellRouter, bootstrapClientShellApp } from "./shellBootstrap.js";
4
13
  export { createComponentInteractionEmitter } from "./componentInteraction.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.86",
3
+ "version": "0.1.88",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "json-rest-schema": "1.x.x"
@@ -14,6 +14,10 @@
14
14
  "types": "./client/index.d.ts",
15
15
  "default": "./client/index.js"
16
16
  },
17
+ "./client/asyncModuleRecovery": {
18
+ "types": "./client/asyncModuleRecovery.d.ts",
19
+ "default": "./client/asyncModuleRecovery.js"
20
+ },
17
21
  "./client/pageRedirects": {
18
22
  "types": "./client/pageRedirects.d.ts",
19
23
  "default": "./client/pageRedirects.js"
@@ -82,10 +82,17 @@ const BARREL_EXPECTATIONS = Object.freeze([
82
82
  expectedExports: Object.freeze([
83
83
  "bootstrapClientShellApp",
84
84
  "createComponentInteractionEmitter",
85
+ "createAsyncModuleRecoveryState",
85
86
  "createShellRouter",
87
+ "dismissAsyncModuleRecovery",
88
+ "dynamicImportErrorMessage",
86
89
  "resolveClientAssetMode",
90
+ "guardedReloadApp",
87
91
  "getClientAppConfig",
92
+ "installAsyncModuleRecoveryHandlers",
93
+ "isDynamicImportError",
88
94
  "normalizeIncomingAppUrl",
95
+ "notifyAsyncModuleLoadError",
89
96
  "registerMobileLaunchRouting",
90
97
  "resolveMobileConfig",
91
98
  "resolveClientBootstrapDebugEnabled"