@jskit-ai/kernel 0.1.62 → 0.1.64
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/client/appConfig.js +16 -2
- package/client/appConfig.test.js +71 -0
- package/client/index.d.ts +24 -0
- package/client/index.js +2 -1
- package/client/mobileLaunchRouting.js +231 -0
- package/client/mobileLaunchRouting.test.js +230 -0
- package/client/shellBootstrap.js +15 -0
- package/client/shellBootstrap.test.js +4 -0
- package/package.json +1 -1
- package/server/http/lib/kernel.test.js +71 -0
- package/server/http/lib/routeRegistration.js +4 -3
- package/server/http/lib/router.js +28 -10
- package/server/support/appConfig.js +93 -2
- package/server/support/appConfig.test.js +102 -1
- package/server/support/appConfigFiles.js +26 -8
- package/server/support/appConfigFiles.test.js +25 -1
- package/server/support/index.js +2 -2
- package/shared/support/normalize.js +92 -0
- package/shared/support/normalize.test.js +112 -0
- package/test/barrelExposure.test.js +4 -0
package/client/appConfig.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRecord } from "../shared/support/normalize.js";
|
|
1
|
+
import { isRecord, normalizeMobileConfig } from "../shared/support/normalize.js";
|
|
2
2
|
|
|
3
3
|
const CLIENT_APP_CONFIG_GLOBAL_KEY = "__JSKIT_CLIENT_APP_CONFIG__";
|
|
4
4
|
const EMPTY_CLIENT_APP_CONFIG = Object.freeze({});
|
|
@@ -30,4 +30,18 @@ function getClientAppConfig() {
|
|
|
30
30
|
return isRecord(appConfig) ? appConfig : EMPTY_CLIENT_APP_CONFIG;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
function resolveMobileConfig(appConfig = getClientAppConfig()) {
|
|
34
|
+
return normalizeMobileConfig(isRecord(appConfig) ? appConfig.mobile : {});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveClientAssetMode(appConfig = getClientAppConfig()) {
|
|
38
|
+
return resolveMobileConfig(appConfig).assetMode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
CLIENT_APP_CONFIG_GLOBAL_KEY,
|
|
43
|
+
setClientAppConfig,
|
|
44
|
+
getClientAppConfig,
|
|
45
|
+
resolveMobileConfig,
|
|
46
|
+
resolveClientAssetMode
|
|
47
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
CLIENT_APP_CONFIG_GLOBAL_KEY,
|
|
5
|
+
getClientAppConfig,
|
|
6
|
+
resolveClientAssetMode,
|
|
7
|
+
resolveMobileConfig,
|
|
8
|
+
setClientAppConfig
|
|
9
|
+
} from "./appConfig.js";
|
|
10
|
+
|
|
11
|
+
test("resolveMobileConfig and resolveClientAssetMode read normalized client mobile config", () => {
|
|
12
|
+
const previous = globalThis[CLIENT_APP_CONFIG_GLOBAL_KEY];
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
setClientAppConfig({
|
|
16
|
+
mobile: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
strategy: "capacitor",
|
|
19
|
+
assetMode: "dev_server",
|
|
20
|
+
auth: {
|
|
21
|
+
customScheme: "convict"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
assert.equal(getClientAppConfig().mobile.enabled, true);
|
|
27
|
+
assert.deepEqual(resolveMobileConfig(), {
|
|
28
|
+
enabled: true,
|
|
29
|
+
strategy: "capacitor",
|
|
30
|
+
appId: "",
|
|
31
|
+
appName: "",
|
|
32
|
+
assetMode: "dev_server",
|
|
33
|
+
devServerUrl: "",
|
|
34
|
+
apiBaseUrl: "",
|
|
35
|
+
auth: {
|
|
36
|
+
callbackPath: "/auth/login",
|
|
37
|
+
customScheme: "convict",
|
|
38
|
+
appLinkDomains: []
|
|
39
|
+
},
|
|
40
|
+
android: {
|
|
41
|
+
packageName: "",
|
|
42
|
+
minSdk: 26,
|
|
43
|
+
targetSdk: 35,
|
|
44
|
+
versionCode: 1,
|
|
45
|
+
versionName: "1.0.0"
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
assert.equal(resolveClientAssetMode(), "dev_server");
|
|
49
|
+
} finally {
|
|
50
|
+
if (previous === undefined) {
|
|
51
|
+
delete globalThis[CLIENT_APP_CONFIG_GLOBAL_KEY];
|
|
52
|
+
} else {
|
|
53
|
+
globalThis[CLIENT_APP_CONFIG_GLOBAL_KEY] = previous;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("resolveMobileConfig accepts explicit appConfig values", () => {
|
|
59
|
+
const mobileConfig = resolveMobileConfig({
|
|
60
|
+
mobile: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
appId: "com.example.app",
|
|
63
|
+
appName: "Example App"
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
assert.equal(mobileConfig.enabled, true);
|
|
68
|
+
assert.equal(mobileConfig.appId, "com.example.app");
|
|
69
|
+
assert.equal(mobileConfig.appName, "Example App");
|
|
70
|
+
assert.equal(resolveClientAssetMode({}), "bundled");
|
|
71
|
+
});
|
package/client/index.d.ts
CHANGED
|
@@ -7,6 +7,29 @@ export type ClientLogger = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export function getClientAppConfig(): Readonly<Record<string, any>>;
|
|
10
|
+
export function resolveMobileConfig(appConfig?: Record<string, any>): Readonly<Record<string, any>>;
|
|
11
|
+
export function resolveClientAssetMode(appConfig?: Record<string, any>): string;
|
|
12
|
+
export function normalizeIncomingAppUrl(
|
|
13
|
+
url?: string,
|
|
14
|
+
mobileConfig?: Record<string, any>,
|
|
15
|
+
options?: {
|
|
16
|
+
currentOrigin?: string;
|
|
17
|
+
allowedHttpOrigins?: string[];
|
|
18
|
+
}
|
|
19
|
+
): string;
|
|
20
|
+
export function registerMobileLaunchRouting(options?: {
|
|
21
|
+
router: any;
|
|
22
|
+
mobileConfig?: Record<string, any>;
|
|
23
|
+
getInitialLaunchUrl?: () => Promise<string> | string;
|
|
24
|
+
subscribeToLaunchUrls?: (handler: (url: string) => void) => (() => void) | void;
|
|
25
|
+
currentOrigin?: string;
|
|
26
|
+
allowedHttpOrigins?: string[];
|
|
27
|
+
logger?: ClientLogger;
|
|
28
|
+
}): Readonly<{
|
|
29
|
+
initialize: () => Promise<string>;
|
|
30
|
+
dispose: () => void;
|
|
31
|
+
applyIncomingUrl: (url?: string, reason?: string) => Promise<string>;
|
|
32
|
+
}>;
|
|
10
33
|
|
|
11
34
|
export function resolveClientBootstrapDebugEnabled(options?: {
|
|
12
35
|
env?: Record<string, any>;
|
|
@@ -52,6 +75,7 @@ export function bootstrapClientShellApp(options?: {
|
|
|
52
75
|
debugEnvKey?: string;
|
|
53
76
|
debugMessage?: string;
|
|
54
77
|
onAfterModulesBootstrapped?: (context: any) => void | Promise<void>;
|
|
78
|
+
onAfterRouterReady?: (context: any) => void | Promise<void>;
|
|
55
79
|
mountSelector?: string;
|
|
56
80
|
}): Promise<
|
|
57
81
|
Readonly<{
|
package/client/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { getClientAppConfig } from "./appConfig.js";
|
|
1
|
+
export { getClientAppConfig, resolveMobileConfig, resolveClientAssetMode } from "./appConfig.js";
|
|
2
|
+
export { normalizeIncomingAppUrl, registerMobileLaunchRouting } from "./mobileLaunchRouting.js";
|
|
2
3
|
export { resolveClientBootstrapDebugEnabled, createSurfaceShellRouter as createShellRouter, bootstrapClientShellApp } from "./shellBootstrap.js";
|
|
3
4
|
export { createComponentInteractionEmitter } from "./componentInteraction.js";
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { normalizePathname } from "../shared/surface/paths.js";
|
|
2
|
+
import { normalizeText } from "../shared/support/normalize.js";
|
|
3
|
+
import { resolveMobileConfig } from "./appConfig.js";
|
|
4
|
+
|
|
5
|
+
function buildNormalizedRoutePath(pathname = "/", search = "", hash = "") {
|
|
6
|
+
const normalizedPathname = normalizePathname(pathname);
|
|
7
|
+
const normalizedSearch = String(search || "").trim().replace(/^\?+/, "");
|
|
8
|
+
const normalizedHash = String(hash || "").trim();
|
|
9
|
+
|
|
10
|
+
return `${normalizedPathname}${normalizedSearch ? `?${normalizedSearch}` : ""}${normalizedHash}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeResolvedRoutePath(value = "", fallback = "") {
|
|
14
|
+
const normalizedValue = normalizeText(value);
|
|
15
|
+
if (!normalizedValue) {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(normalizedValue, "https://jskit.invalid");
|
|
21
|
+
return buildNormalizedRoutePath(parsed.pathname, parsed.search, parsed.hash);
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeAllowedHttpOrigins({ mobileConfig = {}, currentOrigin = "", allowedHttpOrigins = [] } = {}) {
|
|
28
|
+
const origins = new Set();
|
|
29
|
+
|
|
30
|
+
const maybeAddOrigin = (value = "") => {
|
|
31
|
+
const normalizedValue = normalizeText(value);
|
|
32
|
+
if (!normalizedValue) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(normalizedValue);
|
|
38
|
+
const protocol = String(parsed.protocol || "").toLowerCase();
|
|
39
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
origins.add(String(parsed.origin || "").trim().toLowerCase());
|
|
43
|
+
} catch {}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
maybeAddOrigin(currentOrigin);
|
|
47
|
+
|
|
48
|
+
const explicitOrigins = Array.isArray(allowedHttpOrigins) ? allowedHttpOrigins : [allowedHttpOrigins];
|
|
49
|
+
for (const entry of explicitOrigins) {
|
|
50
|
+
maybeAddOrigin(entry);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const appLinkDomains = Array.isArray(mobileConfig?.auth?.appLinkDomains) ? mobileConfig.auth.appLinkDomains : [];
|
|
54
|
+
for (const domain of appLinkDomains) {
|
|
55
|
+
const normalizedDomain = normalizeText(domain).toLowerCase();
|
|
56
|
+
if (!normalizedDomain) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
maybeAddOrigin(`https://${normalizedDomain}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return origins;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeCustomSchemeRoutePath(parsedUrl) {
|
|
66
|
+
const host = normalizeText(parsedUrl?.host);
|
|
67
|
+
const pathname = normalizePathname(parsedUrl?.pathname || "/");
|
|
68
|
+
|
|
69
|
+
if (!host) {
|
|
70
|
+
return pathname;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (pathname === "/") {
|
|
74
|
+
return `/${host}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return normalizePathname(`/${host}${pathname}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeIncomingAppUrl(url = "", mobileConfig = {}, { currentOrigin = "", allowedHttpOrigins = [] } = {}) {
|
|
81
|
+
const normalizedUrl = normalizeText(url);
|
|
82
|
+
if (!normalizedUrl) {
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (normalizedUrl.startsWith("/")) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(normalizedUrl, "https://jskit.invalid");
|
|
89
|
+
return buildNormalizedRoutePath(parsed.pathname, parsed.search, parsed.hash);
|
|
90
|
+
} catch {
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let parsedUrl;
|
|
96
|
+
try {
|
|
97
|
+
parsedUrl = new URL(normalizedUrl);
|
|
98
|
+
} catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const resolvedMobileConfig = resolveMobileConfig({
|
|
103
|
+
mobile: mobileConfig
|
|
104
|
+
});
|
|
105
|
+
const allowedOrigins = normalizeAllowedHttpOrigins({
|
|
106
|
+
mobileConfig: resolvedMobileConfig,
|
|
107
|
+
currentOrigin,
|
|
108
|
+
allowedHttpOrigins
|
|
109
|
+
});
|
|
110
|
+
const protocol = String(parsedUrl.protocol || "").toLowerCase();
|
|
111
|
+
const customScheme = normalizeText(resolvedMobileConfig.auth.customScheme).toLowerCase();
|
|
112
|
+
|
|
113
|
+
if (customScheme && protocol === `${customScheme}:`) {
|
|
114
|
+
return buildNormalizedRoutePath(
|
|
115
|
+
normalizeCustomSchemeRoutePath(parsedUrl),
|
|
116
|
+
parsedUrl.search,
|
|
117
|
+
parsedUrl.hash
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if ((protocol === "http:" || protocol === "https:") && allowedOrigins.has(String(parsedUrl.origin || "").toLowerCase())) {
|
|
122
|
+
return buildNormalizedRoutePath(parsedUrl.pathname, parsedUrl.search, parsedUrl.hash);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function registerMobileLaunchRouting({
|
|
129
|
+
router,
|
|
130
|
+
mobileConfig = {},
|
|
131
|
+
getInitialLaunchUrl = async () => "",
|
|
132
|
+
subscribeToLaunchUrls = () => () => {},
|
|
133
|
+
resolveTargetPath = null,
|
|
134
|
+
currentOrigin = typeof window === "object" && window?.location ? String(window.location.origin || "") : "",
|
|
135
|
+
allowedHttpOrigins = [],
|
|
136
|
+
logger = null
|
|
137
|
+
} = {}) {
|
|
138
|
+
if (!router || typeof router.replace !== "function") {
|
|
139
|
+
throw new TypeError("registerMobileLaunchRouting requires router.replace().");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const resolvedMobileConfig = resolveMobileConfig({
|
|
143
|
+
mobile: mobileConfig
|
|
144
|
+
});
|
|
145
|
+
const runtimeLogger =
|
|
146
|
+
logger && typeof logger === "object"
|
|
147
|
+
? logger
|
|
148
|
+
: {
|
|
149
|
+
info() {},
|
|
150
|
+
warn() {},
|
|
151
|
+
error() {}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
async function applyIncomingUrl(url = "", reason = "manual") {
|
|
155
|
+
const normalizedTargetPath = normalizeIncomingAppUrl(url, resolvedMobileConfig, {
|
|
156
|
+
currentOrigin,
|
|
157
|
+
allowedHttpOrigins
|
|
158
|
+
});
|
|
159
|
+
if (!normalizedTargetPath) {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let resolvedTargetPath = normalizedTargetPath;
|
|
164
|
+
if (typeof resolveTargetPath === "function") {
|
|
165
|
+
const nextTargetPath = await resolveTargetPath(
|
|
166
|
+
Object.freeze({
|
|
167
|
+
originalUrl: String(url || ""),
|
|
168
|
+
normalizedTargetPath,
|
|
169
|
+
reason,
|
|
170
|
+
mobileConfig: resolvedMobileConfig,
|
|
171
|
+
router
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
resolvedTargetPath = normalizeResolvedRoutePath(nextTargetPath, normalizedTargetPath);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const currentFullPath = String(router.currentRoute?.value?.fullPath || "").trim();
|
|
178
|
+
if (currentFullPath === resolvedTargetPath) {
|
|
179
|
+
return resolvedTargetPath;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await router.replace(resolvedTargetPath);
|
|
183
|
+
if (typeof runtimeLogger.info === "function") {
|
|
184
|
+
runtimeLogger.info(
|
|
185
|
+
{
|
|
186
|
+
reason,
|
|
187
|
+
targetPath: resolvedTargetPath
|
|
188
|
+
},
|
|
189
|
+
"Mobile launch routing applied incoming app URL."
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return resolvedTargetPath;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function initialize() {
|
|
196
|
+
if (resolvedMobileConfig.enabled !== true) {
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const initialLaunchUrl = await getInitialLaunchUrl();
|
|
201
|
+
return applyIncomingUrl(initialLaunchUrl, "initial-launch");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const unsubscribe =
|
|
205
|
+
resolvedMobileConfig.enabled === true
|
|
206
|
+
? subscribeToLaunchUrls((nextUrl) => {
|
|
207
|
+
Promise.resolve(applyIncomingUrl(nextUrl, "launch-event")).catch((error) => {
|
|
208
|
+
if (typeof runtimeLogger.warn === "function") {
|
|
209
|
+
runtimeLogger.warn(
|
|
210
|
+
{
|
|
211
|
+
error: String(error?.message || error || "unknown error")
|
|
212
|
+
},
|
|
213
|
+
"Mobile launch routing failed to apply incoming app URL."
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
})
|
|
218
|
+
: () => {};
|
|
219
|
+
|
|
220
|
+
return Object.freeze({
|
|
221
|
+
initialize,
|
|
222
|
+
dispose() {
|
|
223
|
+
if (typeof unsubscribe === "function") {
|
|
224
|
+
unsubscribe();
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
applyIncomingUrl
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export { normalizeIncomingAppUrl, registerMobileLaunchRouting };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { normalizeIncomingAppUrl, registerMobileLaunchRouting } from "./mobileLaunchRouting.js";
|
|
4
|
+
|
|
5
|
+
test("normalizeIncomingAppUrl normalizes custom-scheme auth callback routes into router paths", () => {
|
|
6
|
+
const normalized = normalizeIncomingAppUrl("convict://auth/login?code=abc&oauthProvider=google", {
|
|
7
|
+
enabled: true,
|
|
8
|
+
auth: {
|
|
9
|
+
customScheme: "convict"
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
assert.equal(normalized, "/auth/login?code=abc&oauthProvider=google");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("normalizeIncomingAppUrl normalizes custom-scheme workspace routes", () => {
|
|
17
|
+
const normalized = normalizeIncomingAppUrl("convict://w/acme/workouts/2026-05-07?tab=today", {
|
|
18
|
+
enabled: true,
|
|
19
|
+
auth: {
|
|
20
|
+
customScheme: "convict"
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
assert.equal(normalized, "/w/acme/workouts/2026-05-07?tab=today");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("normalizeIncomingAppUrl normalizes allowed HTTPS app links", () => {
|
|
28
|
+
const normalized = normalizeIncomingAppUrl("https://app.example.com/auth/login?code=abc", {
|
|
29
|
+
enabled: true,
|
|
30
|
+
auth: {
|
|
31
|
+
appLinkDomains: ["app.example.com"]
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
assert.equal(normalized, "/auth/login?code=abc");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("normalizeIncomingAppUrl accepts same-origin HTTP URLs when explicitly allowed", () => {
|
|
39
|
+
const normalized = normalizeIncomingAppUrl(
|
|
40
|
+
"http://192.168.1.10:5173/w/acme",
|
|
41
|
+
{
|
|
42
|
+
enabled: true
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
allowedHttpOrigins: ["http://192.168.1.10:5173"]
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
assert.equal(normalized, "/w/acme");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("normalizeIncomingAppUrl rejects unowned schemes and domains", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
normalizeIncomingAppUrl("otherapp://auth/login?code=abc", {
|
|
55
|
+
enabled: true,
|
|
56
|
+
auth: {
|
|
57
|
+
customScheme: "convict"
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
""
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
assert.equal(
|
|
64
|
+
normalizeIncomingAppUrl("https://evil.example.com/auth/login?code=abc", {
|
|
65
|
+
enabled: true,
|
|
66
|
+
auth: {
|
|
67
|
+
appLinkDomains: ["app.example.com"]
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
""
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("registerMobileLaunchRouting initializes and applies the initial launch URL", async () => {
|
|
75
|
+
const replaceCalls = [];
|
|
76
|
+
const runtime = registerMobileLaunchRouting({
|
|
77
|
+
router: {
|
|
78
|
+
currentRoute: {
|
|
79
|
+
value: {
|
|
80
|
+
fullPath: "/home"
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
async replace(target) {
|
|
84
|
+
replaceCalls.push(target);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
mobileConfig: {
|
|
88
|
+
enabled: true,
|
|
89
|
+
auth: {
|
|
90
|
+
customScheme: "convict"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
getInitialLaunchUrl: async () => "convict://auth/login?code=abc"
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const targetPath = await runtime.initialize();
|
|
97
|
+
|
|
98
|
+
assert.equal(targetPath, "/auth/login?code=abc");
|
|
99
|
+
assert.deepEqual(replaceCalls, ["/auth/login?code=abc"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("registerMobileLaunchRouting subscribes to later launch URLs and routes them", async () => {
|
|
103
|
+
const replaceCalls = [];
|
|
104
|
+
let listener = null;
|
|
105
|
+
const runtime = registerMobileLaunchRouting({
|
|
106
|
+
router: {
|
|
107
|
+
currentRoute: {
|
|
108
|
+
value: {
|
|
109
|
+
fullPath: "/home"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async replace(target) {
|
|
113
|
+
replaceCalls.push(target);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
mobileConfig: {
|
|
117
|
+
enabled: true,
|
|
118
|
+
auth: {
|
|
119
|
+
customScheme: "convict"
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
subscribeToLaunchUrls(handler) {
|
|
123
|
+
listener = handler;
|
|
124
|
+
return () => {
|
|
125
|
+
listener = null;
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.equal(typeof listener, "function");
|
|
131
|
+
listener("convict://w/acme");
|
|
132
|
+
await Promise.resolve();
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
assert.deepEqual(replaceCalls, ["/w/acme"]);
|
|
135
|
+
|
|
136
|
+
runtime.dispose();
|
|
137
|
+
assert.equal(listener, null);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("registerMobileLaunchRouting lets a resolver override the final route target", async () => {
|
|
141
|
+
const replaceCalls = [];
|
|
142
|
+
const runtime = registerMobileLaunchRouting({
|
|
143
|
+
router: {
|
|
144
|
+
currentRoute: {
|
|
145
|
+
value: {
|
|
146
|
+
fullPath: "/home"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
async replace(target) {
|
|
150
|
+
replaceCalls.push(target);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
mobileConfig: {
|
|
154
|
+
enabled: true,
|
|
155
|
+
auth: {
|
|
156
|
+
customScheme: "convict"
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
getInitialLaunchUrl: async () => "convict://auth/login?code=abc",
|
|
160
|
+
resolveTargetPath({ originalUrl, normalizedTargetPath, reason }) {
|
|
161
|
+
assert.equal(originalUrl, "convict://auth/login?code=abc");
|
|
162
|
+
assert.equal(normalizedTargetPath, "/auth/login?code=abc");
|
|
163
|
+
assert.equal(reason, "initial-launch");
|
|
164
|
+
return "/w/acme";
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const targetPath = await runtime.initialize();
|
|
169
|
+
|
|
170
|
+
assert.equal(targetPath, "/w/acme");
|
|
171
|
+
assert.deepEqual(replaceCalls, ["/w/acme"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("registerMobileLaunchRouting forwards unknown deep-link paths to the normal router", async () => {
|
|
175
|
+
const replaceCalls = [];
|
|
176
|
+
const runtime = registerMobileLaunchRouting({
|
|
177
|
+
router: {
|
|
178
|
+
currentRoute: {
|
|
179
|
+
value: {
|
|
180
|
+
fullPath: "/home"
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
async replace(target) {
|
|
184
|
+
replaceCalls.push(target);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
mobileConfig: {
|
|
188
|
+
enabled: true,
|
|
189
|
+
auth: {
|
|
190
|
+
customScheme: "convict"
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
getInitialLaunchUrl: async () => "convict://w/acme/does-not-exist"
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const targetPath = await runtime.initialize();
|
|
197
|
+
|
|
198
|
+
assert.equal(targetPath, "/w/acme/does-not-exist");
|
|
199
|
+
assert.deepEqual(replaceCalls, ["/w/acme/does-not-exist"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("registerMobileLaunchRouting no-ops when mobile config is disabled", async () => {
|
|
203
|
+
const replaceCalls = [];
|
|
204
|
+
const runtime = registerMobileLaunchRouting({
|
|
205
|
+
router: {
|
|
206
|
+
currentRoute: {
|
|
207
|
+
value: {
|
|
208
|
+
fullPath: "/home"
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async replace(target) {
|
|
212
|
+
replaceCalls.push(target);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
mobileConfig: {
|
|
216
|
+
enabled: false,
|
|
217
|
+
auth: {
|
|
218
|
+
customScheme: "convict"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
getInitialLaunchUrl: async () => "convict://auth/login?code=abc",
|
|
222
|
+
subscribeToLaunchUrls() {
|
|
223
|
+
throw new Error("subscribeToLaunchUrls should not be called when mobile is disabled");
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const targetPath = await runtime.initialize();
|
|
228
|
+
assert.equal(targetPath, "");
|
|
229
|
+
assert.deepEqual(replaceCalls, []);
|
|
230
|
+
});
|
package/client/shellBootstrap.js
CHANGED
|
@@ -121,6 +121,7 @@ async function bootstrapClientShellApp({
|
|
|
121
121
|
debugEnvKey = "VITE_JSKIT_CLIENT_DEBUG",
|
|
122
122
|
debugMessage = "Client modules bootstrapped before router install.",
|
|
123
123
|
onAfterModulesBootstrapped = null,
|
|
124
|
+
onAfterRouterReady = null,
|
|
124
125
|
mountSelector = "#app"
|
|
125
126
|
} = {}) {
|
|
126
127
|
if (typeof createApp !== "function") {
|
|
@@ -216,6 +217,20 @@ async function bootstrapClientShellApp({
|
|
|
216
217
|
if (typeof router.isReady === "function") {
|
|
217
218
|
await router.isReady();
|
|
218
219
|
}
|
|
220
|
+
if (typeof onAfterRouterReady === "function") {
|
|
221
|
+
await onAfterRouterReady(
|
|
222
|
+
Object.freeze({
|
|
223
|
+
app,
|
|
224
|
+
router,
|
|
225
|
+
clientBootstrap,
|
|
226
|
+
surfaceRuntime,
|
|
227
|
+
surfaceMode,
|
|
228
|
+
env: isRecord(env) ? { ...env } : {},
|
|
229
|
+
logger: bootstrapLogger,
|
|
230
|
+
debugEnabled: isDebugEnabled
|
|
231
|
+
})
|
|
232
|
+
);
|
|
233
|
+
}
|
|
219
234
|
app.mount(mountSelector);
|
|
220
235
|
|
|
221
236
|
return Object.freeze({
|
|
@@ -168,6 +168,9 @@ test("bootstrapClientShellApp boots modules, reinstalls fallback route, and moun
|
|
|
168
168
|
},
|
|
169
169
|
onAfterModulesBootstrapped(context) {
|
|
170
170
|
calls.push(`after:${context.clientBootstrap.routeCount}`);
|
|
171
|
+
},
|
|
172
|
+
onAfterRouterReady(context) {
|
|
173
|
+
calls.push(`router-ready:${context.clientBootstrap.routeCount}`);
|
|
171
174
|
}
|
|
172
175
|
});
|
|
173
176
|
|
|
@@ -185,4 +188,5 @@ test("bootstrapClientShellApp boots modules, reinstalls fallback route, and moun
|
|
|
185
188
|
assert.equal(calls.includes("add:not-found"), true);
|
|
186
189
|
assert.equal(calls.includes("isReady"), true);
|
|
187
190
|
assert.equal(calls.includes("after:3"), true);
|
|
191
|
+
assert.equal(calls.includes("router-ready:3"), true);
|
|
188
192
|
});
|