@terreno/admin-spa 58.0.0-beta.2 → 58.0.0-beta.4
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/CHANGELOG.md +146 -0
- package/README.md +43 -24
- package/package.json +13 -12
- package/src/dist/gate.d.ts +31 -0
- package/src/dist/gate.js +263 -0
- package/src/dist/returnTo.d.ts +8 -0
- package/src/dist/returnTo.js +24 -0
- package/src/dist/routesManifest.d.ts +35 -0
- package/src/dist/routesManifest.js +107 -0
- package/src/dist/serve.d.ts +24 -3
- package/src/dist/serve.js +121 -26
- package/src/dist/serverContext.d.ts +61 -0
- package/src/dist/serverContext.js +88 -0
- package/src/dist/signedInOnlyRoutes.d.ts +2 -0
- package/src/dist/signedInOnlyRoutes.js +10 -0
package/src/dist/gate.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAdminSpaGate = void 0;
|
|
4
|
+
const api_1 = require("@terreno/api");
|
|
5
|
+
const returnTo_1 = require("./returnTo");
|
|
6
|
+
const serverContext_1 = require("./serverContext");
|
|
7
|
+
const signedInOnlyRoutes_1 = require("./signedInOnlyRoutes");
|
|
8
|
+
/** Longest the gate waits on any of its loopback reads before failing closed. */
|
|
9
|
+
const UPSTREAM_TIMEOUT_MS = 5000;
|
|
10
|
+
/** Where Expo's client fetches route loader data, at the site root and under `basePath`. */
|
|
11
|
+
const LOADERS_PATH = "/_expo/loaders";
|
|
12
|
+
/** Paths under `basePath` that are never gated: hashed assets and the runtime config. */
|
|
13
|
+
const UNGATED_PREFIXES = ["/_expo/static", "/assets", "/app-config.json"];
|
|
14
|
+
/** Routes an anonymous or forbidden visitor may render (and load data for). */
|
|
15
|
+
const PUBLIC_ROUTES = new Set(["/login", "/forbidden"]);
|
|
16
|
+
/** Statuses the gate understands; anything else fails closed. */
|
|
17
|
+
const KNOWN_STATUSES = new Set([200, 401, 403]);
|
|
18
|
+
const ANONYMOUS = {
|
|
19
|
+
access: "anonymous",
|
|
20
|
+
adminConfig: null,
|
|
21
|
+
adminConfigStatus: null,
|
|
22
|
+
profile: null,
|
|
23
|
+
session: null,
|
|
24
|
+
};
|
|
25
|
+
/** Thrown when an upstream read fails in a way the gate cannot decide on. */
|
|
26
|
+
class GateUnavailableError extends Error {
|
|
27
|
+
}
|
|
28
|
+
/** Base for resolving a request URL on its own. */
|
|
29
|
+
const URL_BASE = "http://gate.invalid";
|
|
30
|
+
/** Characters RFC 3986 calls unreserved: an escape of one of them means the character itself. */
|
|
31
|
+
const UNRESERVED = /^[A-Za-z0-9\-._~]$/;
|
|
32
|
+
/**
|
|
33
|
+
* RFC 3986 percent-encoding normalization: decode escapes of unreserved characters and
|
|
34
|
+
* uppercase the hex of the rest. `/console/User/%36ae…` and `/console/User/6ae…` name the same
|
|
35
|
+
* record, and Expo keys loader data by the raw path while the router looks it up by the decoded
|
|
36
|
+
* params, so only the canonical form renders with its data. The URL parser already treats `%2E`
|
|
37
|
+
* as a dot, so this changes no dot-segment decision.
|
|
38
|
+
*/
|
|
39
|
+
const canonicalizeEscapes = (url) => {
|
|
40
|
+
return url.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
|
|
41
|
+
const character = String.fromCharCode(Number.parseInt(hex, 16));
|
|
42
|
+
return UNRESERVED.test(character) ? character : match.toUpperCase();
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Classify a request by its normalized path: dot segments, including encoded ones, resolved
|
|
47
|
+
* the way Expo will resolve them. Deciding on the raw path would let `/console/assets/../Todo`
|
|
48
|
+
* pass as an asset and then render as `/Todo`.
|
|
49
|
+
*/
|
|
50
|
+
const classify = (method, requestPath, basePath) => {
|
|
51
|
+
const isRead = method === "GET" || method === "HEAD";
|
|
52
|
+
if (requestPath.startsWith(`${LOADERS_PATH}/`)) {
|
|
53
|
+
return isRead
|
|
54
|
+
? { kind: "loader", route: requestPath.slice(LOADERS_PATH.length) }
|
|
55
|
+
: { kind: "write" };
|
|
56
|
+
}
|
|
57
|
+
if (requestPath !== basePath && !requestPath.startsWith(`${basePath}/`)) {
|
|
58
|
+
return { kind: "outside" };
|
|
59
|
+
}
|
|
60
|
+
if (!isRead) {
|
|
61
|
+
return { kind: "write" };
|
|
62
|
+
}
|
|
63
|
+
const inner = requestPath.slice(basePath.length) || "/";
|
|
64
|
+
if (inner.startsWith(`${LOADERS_PATH}/`)) {
|
|
65
|
+
return { kind: "loader", route: inner.slice(LOADERS_PATH.length) };
|
|
66
|
+
}
|
|
67
|
+
if (UNGATED_PREFIXES.some((prefix) => inner === prefix || inner.startsWith(`${prefix}/`))) {
|
|
68
|
+
return { kind: "static" };
|
|
69
|
+
}
|
|
70
|
+
return { kind: "page", route: inner };
|
|
71
|
+
};
|
|
72
|
+
/** One loopback read with the visitor's cookie. Only a 200 body is read. */
|
|
73
|
+
const readUpstream = async (url, cookie) => {
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetch(url, {
|
|
77
|
+
headers: cookie ? { Accept: "application/json", Cookie: cookie } : { Accept: "application/json" },
|
|
78
|
+
redirect: "manual",
|
|
79
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const reason = error instanceof Error ? error.name : "unknown error";
|
|
84
|
+
throw new GateUnavailableError(`${new URL(url).pathname} failed: ${reason}`);
|
|
85
|
+
}
|
|
86
|
+
if (!KNOWN_STATUSES.has(response.status)) {
|
|
87
|
+
throw new GateUnavailableError(`${new URL(url).pathname} answered ${response.status}`);
|
|
88
|
+
}
|
|
89
|
+
if (response.status !== 200) {
|
|
90
|
+
return { body: null, status: response.status };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
return { body: await response.json(), status: 200 };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new GateUnavailableError(`${new URL(url).pathname} answered a body that is not JSON`);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/** The session snapshot from a Better Auth `get-session` body, or null when signed out. */
|
|
100
|
+
const sessionSnapshotOf = (body) => {
|
|
101
|
+
const value = body;
|
|
102
|
+
const userId = value?.session?.userId;
|
|
103
|
+
if (typeof userId !== "string" || !value?.user) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return { user: value.user, userId };
|
|
107
|
+
};
|
|
108
|
+
/** Read the session, profile and admin config in parallel and decide the visitor's access. */
|
|
109
|
+
const loadShellPayload = async (req, { adminApiBasePath, appConfig, internalOrigin }) => {
|
|
110
|
+
const cookie = req.headers.cookie;
|
|
111
|
+
const authBasePath = appConfig.authBasePath ?? "/api/auth";
|
|
112
|
+
const [session, profile, config] = await Promise.all([
|
|
113
|
+
readUpstream(`${internalOrigin}${authBasePath}/get-session`, cookie),
|
|
114
|
+
readUpstream(`${internalOrigin}/auth/me`, cookie),
|
|
115
|
+
readUpstream(`${internalOrigin}${adminApiBasePath}/config`, cookie),
|
|
116
|
+
]);
|
|
117
|
+
const snapshot = session.status === 200 ? sessionSnapshotOf(session.body) : null;
|
|
118
|
+
if (!snapshot || profile.status === 401 || config.status === 401) {
|
|
119
|
+
return ANONYMOUS;
|
|
120
|
+
}
|
|
121
|
+
const profileBody = profile.status === 200 ? profile.body : null;
|
|
122
|
+
if (config.status === 403) {
|
|
123
|
+
return {
|
|
124
|
+
access: "forbidden",
|
|
125
|
+
adminConfig: null,
|
|
126
|
+
adminConfigStatus: 403,
|
|
127
|
+
profile: profileBody,
|
|
128
|
+
session: snapshot,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
access: "admin",
|
|
133
|
+
adminConfig: config.body,
|
|
134
|
+
adminConfigStatus: 200,
|
|
135
|
+
profile: profileBody,
|
|
136
|
+
session: snapshot,
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Express middleware that decides access before the admin SPA renders anything. Mounted at
|
|
141
|
+
* the site root, it gates `GET` page and loader requests under `basePath` and loader requests
|
|
142
|
+
* at the root `/_expo/loaders`, and passes every other path through.
|
|
143
|
+
*
|
|
144
|
+
* - Anonymous: pages other than `/login` redirect to `{basePath}/login?returnTo=<path>`;
|
|
145
|
+
* loaders answer 401 JSON (the `login` and `forbidden` loaders run).
|
|
146
|
+
* - Signed in but not an admin (`/admin/config` 403): signed-in-only routes (`/oauth/consent`)
|
|
147
|
+
* render, `/login` with a safe `returnTo` redirects there; other pages redirect to
|
|
148
|
+
* `{basePath}/forbidden`;
|
|
149
|
+
* loaders answer 403 JSON.
|
|
150
|
+
* - Admin: `/login` redirects to a safe `returnTo` (or `{basePath}/`); everything else renders
|
|
151
|
+
* inside a request scope holding the session, profile and admin config, so the page renders
|
|
152
|
+
* with its shell instead of a spinner.
|
|
153
|
+
* - An upstream answer other than 200 / 401 / 403, a network error or a 5 s timeout answers
|
|
154
|
+
* 503 without rendering. A 401 counts as anonymous.
|
|
155
|
+
*/
|
|
156
|
+
const createAdminSpaGate = (options) => {
|
|
157
|
+
const { appConfig, handler } = options;
|
|
158
|
+
const basePath = options.basePath === "/" ? "" : options.basePath;
|
|
159
|
+
const render = (req, res, next, payload) => {
|
|
160
|
+
const scope = {
|
|
161
|
+
adminConfig: payload.adminConfig,
|
|
162
|
+
adminConfigStatus: payload.adminConfigStatus,
|
|
163
|
+
appConfig,
|
|
164
|
+
basePath: options.basePath,
|
|
165
|
+
profile: payload.profile,
|
|
166
|
+
session: payload.session,
|
|
167
|
+
};
|
|
168
|
+
(0, serverContext_1.runInAdminSpaRequest)(scope, () => handler(req, res, next));
|
|
169
|
+
};
|
|
170
|
+
// Every decision depends on the visitor's session, so no redirect may be cached or shared.
|
|
171
|
+
const redirect = (res, location) => {
|
|
172
|
+
res.set("Cache-Control", "no-store, private").redirect(302, location);
|
|
173
|
+
};
|
|
174
|
+
const deny = (res, status) => {
|
|
175
|
+
res
|
|
176
|
+
.status(status)
|
|
177
|
+
.set("Cache-Control", "no-store, private")
|
|
178
|
+
.json({ title: status === 401 ? "Unauthorized" : "Forbidden" });
|
|
179
|
+
};
|
|
180
|
+
return (req, res, next) => {
|
|
181
|
+
const normalized = new URL(canonicalizeEscapes(req.url), URL_BASE);
|
|
182
|
+
const classified = classify(req.method, normalized.pathname, basePath);
|
|
183
|
+
// Static paths are served by the static middleware mounted before the gate. A miss must
|
|
184
|
+
// never reach the page renderer, which would render it as a route (`/assets/Todo` matches
|
|
185
|
+
// `[model]/[id]`) without an access decision.
|
|
186
|
+
if (classified.kind === "outside" || classified.kind === "static") {
|
|
187
|
+
next();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Hand the renderer exactly the path the gate decided on.
|
|
191
|
+
req.url = `${normalized.pathname}${normalized.search}`;
|
|
192
|
+
// The admin SPA serves only reads: a write never reaches the renderer or a loader.
|
|
193
|
+
if (classified.kind === "write") {
|
|
194
|
+
next();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const decide = async () => {
|
|
198
|
+
const payload = req.user ? await loadShellPayload(req, options) : ANONYMOUS;
|
|
199
|
+
const { route } = classified;
|
|
200
|
+
const isPublic = PUBLIC_ROUTES.has(route);
|
|
201
|
+
// Signed-in-only routes (MCP OAuth consent) render for any signed-in user.
|
|
202
|
+
const isOpenToVisitor = isPublic || (payload.access !== "anonymous" && (0, signedInOnlyRoutes_1.isSignedInOnlyRoute)(route));
|
|
203
|
+
if (classified.kind === "loader") {
|
|
204
|
+
if (payload.access === "admin" || isOpenToVisitor) {
|
|
205
|
+
render(req, res, next, payload);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
deny(res, payload.access === "anonymous" ? 401 : 403);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (payload.access === "anonymous") {
|
|
212
|
+
if (route === "/login") {
|
|
213
|
+
render(req, res, next, payload);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const search = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
|
|
217
|
+
const returnTo = encodeURIComponent(`${route}${search}`);
|
|
218
|
+
redirect(res, `${basePath}/login?returnTo=${returnTo}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (payload.access === "forbidden") {
|
|
222
|
+
// Like the client gate: a signed-in user on /login goes to a safe returnTo, which
|
|
223
|
+
// renders when signed-in-only (the MCP consent flow) and otherwise redirects again.
|
|
224
|
+
const forbiddenTarget = route === "/login"
|
|
225
|
+
? (0, returnTo_1.sanitizeReturnTo)(req.query.returnTo)
|
|
226
|
+
: null;
|
|
227
|
+
if (forbiddenTarget) {
|
|
228
|
+
redirect(res, `${basePath}${forbiddenTarget}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (isOpenToVisitor) {
|
|
232
|
+
render(req, res, next, payload);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
redirect(res, `${basePath}/forbidden`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (route === "/login") {
|
|
239
|
+
const target = (0, returnTo_1.sanitizeReturnTo)(req.query.returnTo);
|
|
240
|
+
redirect(res, `${basePath}${target ?? "/"}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (route === "/forbidden") {
|
|
244
|
+
redirect(res, `${basePath}/`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
render(req, res, next, payload);
|
|
248
|
+
};
|
|
249
|
+
decide().catch((error) => {
|
|
250
|
+
if (!(error instanceof GateUnavailableError)) {
|
|
251
|
+
next(error);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
api_1.logger.warn(`Admin SPA gate failed closed for ${req.path}`, { reason: error.message });
|
|
255
|
+
res
|
|
256
|
+
.status(503)
|
|
257
|
+
.set("Cache-Control", "no-store, private")
|
|
258
|
+
.type("text")
|
|
259
|
+
.send("Admin SPA unavailable");
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
exports.createAdminSpaGate = createAdminSpaGate;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accept a post-login destination only when it is an in-app path ("/oauth/consent?request=abc"):
|
|
3
|
+
* one leading `/`, no second `/` right after it, no backslash, whitespace or control character,
|
|
4
|
+
* and not the login page itself. Anything else (an absolute URL, a protocol-relative `//host`,
|
|
5
|
+
* `/\\host`, a relative path) is an open redirect and yields undefined. A repeated query
|
|
6
|
+
* parameter uses its first value. The server gate and the client share this one rule.
|
|
7
|
+
*/
|
|
8
|
+
export declare const sanitizeReturnTo: (value: unknown) => string | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeReturnTo = void 0;
|
|
4
|
+
// Any ASCII control character or whitespace; a valid in-app path never contains one.
|
|
5
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control characters is the point
|
|
6
|
+
const UNSAFE_RETURN_TO_CHARS = /[\u0000-\u001f\u007f\s\\]/;
|
|
7
|
+
/**
|
|
8
|
+
* Accept a post-login destination only when it is an in-app path ("/oauth/consent?request=abc"):
|
|
9
|
+
* one leading `/`, no second `/` right after it, no backslash, whitespace or control character,
|
|
10
|
+
* and not the login page itself. Anything else (an absolute URL, a protocol-relative `//host`,
|
|
11
|
+
* `/\\host`, a relative path) is an open redirect and yields undefined. A repeated query
|
|
12
|
+
* parameter uses its first value. The server gate and the client share this one rule.
|
|
13
|
+
*/
|
|
14
|
+
const sanitizeReturnTo = (value) => {
|
|
15
|
+
const candidate = Array.isArray(value) ? value[0] : value;
|
|
16
|
+
if (typeof candidate !== "string" || !candidate.startsWith("/") || candidate.startsWith("//")) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (UNSAFE_RETURN_TO_CHARS.test(candidate) || candidate.startsWith("/login")) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
return candidate;
|
|
23
|
+
};
|
|
24
|
+
exports.sanitizeReturnTo = sanitizeReturnTo;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
interface RawRoute {
|
|
2
|
+
page: string;
|
|
3
|
+
namedRegex: string;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
/** The parts of an Expo server build's `_expo/routes.json` the serve plugin reads. */
|
|
7
|
+
export interface RawRoutesManifest {
|
|
8
|
+
assets?: {
|
|
9
|
+
js?: string[];
|
|
10
|
+
};
|
|
11
|
+
htmlRoutes?: RawRoute[];
|
|
12
|
+
apiRoutes?: RawRoute[];
|
|
13
|
+
notFoundRoutes?: RawRoute[];
|
|
14
|
+
redirects?: RawRoute[];
|
|
15
|
+
rewrites?: RawRoute[];
|
|
16
|
+
pageHeaders?: RawRoute[];
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
/** Read `_expo/routes.json` from a server build, or undefined when there is no build. */
|
|
20
|
+
export declare const readRoutesManifest: (serverDir: string) => RawRoutesManifest | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The baseUrl baked into the build, read from its asset URLs (`/console/_expo/static/...`).
|
|
23
|
+
* "" for a bundle built for the site root.
|
|
24
|
+
*/
|
|
25
|
+
export declare const bakedBaseUrlOf: (manifest: RawRoutesManifest) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Prepare the manifest the way expo-server's environment does (regex strings become
|
|
28
|
+
* `RegExp`s), with HTML routes ordered by specificity. Expo 58 exports `routes.json`
|
|
29
|
+
* unsorted, and expo-server takes the first regex match, so `/[model]` would shadow `/login`
|
|
30
|
+
* and render it without its loader data. The router itself already prefers static routes.
|
|
31
|
+
*/
|
|
32
|
+
export declare const prepareRoutesManifest: (raw: RawRoutesManifest, { pageCacheControl }?: {
|
|
33
|
+
pageCacheControl?: string;
|
|
34
|
+
}) => Record<string, unknown>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.prepareRoutesManifest = exports.bakedBaseUrlOf = exports.readRoutesManifest = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const REGEX_LISTS = [
|
|
10
|
+
"htmlRoutes",
|
|
11
|
+
"apiRoutes",
|
|
12
|
+
"notFoundRoutes",
|
|
13
|
+
"redirects",
|
|
14
|
+
"rewrites",
|
|
15
|
+
"pageHeaders",
|
|
16
|
+
];
|
|
17
|
+
/** Read `_expo/routes.json` from a server build, or undefined when there is no build. */
|
|
18
|
+
const readRoutesManifest = (serverDir) => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(serverDir, "_expo", "routes.json"), "utf-8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
exports.readRoutesManifest = readRoutesManifest;
|
|
27
|
+
/**
|
|
28
|
+
* The baseUrl baked into the build, read from its asset URLs (`/console/_expo/static/...`).
|
|
29
|
+
* "" for a bundle built for the site root.
|
|
30
|
+
*/
|
|
31
|
+
const bakedBaseUrlOf = (manifest) => {
|
|
32
|
+
const firstAsset = manifest.assets?.js?.[0] ?? "";
|
|
33
|
+
const index = firstAsset.indexOf("/_expo/");
|
|
34
|
+
return index > 0 ? firstAsset.slice(0, index) : "";
|
|
35
|
+
};
|
|
36
|
+
exports.bakedBaseUrlOf = bakedBaseUrlOf;
|
|
37
|
+
/** Expo Router's match priority for one path segment: static, then dynamic, then catch-all. */
|
|
38
|
+
const segmentRank = (segment) => {
|
|
39
|
+
if (segment.startsWith("[...")) {
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
if (segment.startsWith("[")) {
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
};
|
|
47
|
+
const routeRanks = (page) => {
|
|
48
|
+
return page
|
|
49
|
+
.split("/")
|
|
50
|
+
.filter((segment) => segment !== "" && !(segment.startsWith("(") && segment.endsWith(")")))
|
|
51
|
+
.map(segmentRank);
|
|
52
|
+
};
|
|
53
|
+
/** Compare two routes segment by segment: the more specific route sorts first. */
|
|
54
|
+
const compareSpecificity = (a, b) => {
|
|
55
|
+
const aRanks = routeRanks(a.page);
|
|
56
|
+
const bRanks = routeRanks(b.page);
|
|
57
|
+
const length = Math.min(aRanks.length, bRanks.length);
|
|
58
|
+
for (let index = 0; index < length; index++) {
|
|
59
|
+
const difference = (aRanks[index] ?? 0) - (bRanks[index] ?? 0);
|
|
60
|
+
if (difference !== 0) {
|
|
61
|
+
return difference;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Replace every `Cache-Control` in the manifest's `pageHeaders` rules. Expo exports a
|
|
68
|
+
* `no-store` rule for every loader response, and expo-server applies it after Express
|
|
69
|
+
* middleware, so the plugin's own header would be overwritten.
|
|
70
|
+
*/
|
|
71
|
+
const withCacheControl = (routes, cacheControl) => {
|
|
72
|
+
return routes.map((route) => {
|
|
73
|
+
const headers = route.headers;
|
|
74
|
+
if (!headers) {
|
|
75
|
+
return route;
|
|
76
|
+
}
|
|
77
|
+
const kept = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "cache-control"));
|
|
78
|
+
const hadCacheControl = Object.keys(kept).length !== Object.keys(headers).length;
|
|
79
|
+
return hadCacheControl ? { ...route, headers: { ...kept, "Cache-Control": cacheControl } } : route;
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Prepare the manifest the way expo-server's environment does (regex strings become
|
|
84
|
+
* `RegExp`s), with HTML routes ordered by specificity. Expo 58 exports `routes.json`
|
|
85
|
+
* unsorted, and expo-server takes the first regex match, so `/[model]` would shadow `/login`
|
|
86
|
+
* and render it without its loader data. The router itself already prefers static routes.
|
|
87
|
+
*/
|
|
88
|
+
const prepareRoutesManifest = (raw, { pageCacheControl } = {}) => {
|
|
89
|
+
const prepared = { ...raw };
|
|
90
|
+
for (const list of REGEX_LISTS) {
|
|
91
|
+
const exported = raw[list];
|
|
92
|
+
const routes = list === "pageHeaders" && exported && pageCacheControl
|
|
93
|
+
? withCacheControl(exported, pageCacheControl)
|
|
94
|
+
: exported;
|
|
95
|
+
if (!routes) {
|
|
96
|
+
// expo-server defaults every list except `pageHeaders` to empty.
|
|
97
|
+
if (list !== "pageHeaders") {
|
|
98
|
+
prepared[list] = [];
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const ordered = list === "htmlRoutes" ? [...routes].sort(compareSpecificity) : routes;
|
|
103
|
+
prepared[list] = ordered.map((route) => ({ ...route, namedRegex: new RegExp(route.namedRegex) }));
|
|
104
|
+
}
|
|
105
|
+
return prepared;
|
|
106
|
+
};
|
|
107
|
+
exports.prepareRoutesManifest = prepareRoutesManifest;
|
package/src/dist/serve.d.ts
CHANGED
|
@@ -6,28 +6,49 @@ export interface AdminSpaServeOptions {
|
|
|
6
6
|
basePath?: string;
|
|
7
7
|
/** Runtime config served at `${basePath}/app-config.json`. Merged with defaults. */
|
|
8
8
|
appConfig?: Partial<AdminSpaAppConfig>;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Override the export root the bundle is served from: it holds `client/` (static files)
|
|
11
|
+
* and `server/` (the Expo server build). Used by tests, custom builds, and compiled binaries.
|
|
12
|
+
*/
|
|
10
13
|
distDir?: string;
|
|
11
14
|
/**
|
|
12
15
|
* In dev, proxy all SPA paths to a running `expo start --web` server, e.g.
|
|
13
16
|
* "http://localhost:8083". Avoids re-running `expo export` per change.
|
|
14
17
|
*/
|
|
15
18
|
devProxyTarget?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Origin the server render fetches API data from, with the visitor's cookie. Default:
|
|
21
|
+
* `http://127.0.0.1:${PORT ?? 4000}`, this process. Never derived from `Host` or
|
|
22
|
+
* `X-Forwarded-*`, so a request cannot point the render at another host.
|
|
23
|
+
*/
|
|
24
|
+
internalOrigin?: string;
|
|
25
|
+
/**
|
|
26
|
+
* API path prefixes a server render may fetch. Default: the admin API base path plus
|
|
27
|
+
* `/auth/me`, `/orgs`, `/jobs`, `/comms`, `/configuration`, and `/rbac`.
|
|
28
|
+
*/
|
|
29
|
+
ssrApiPrefixes?: string[];
|
|
16
30
|
}
|
|
17
31
|
/**
|
|
32
|
+
* @deprecated The server-rendered build no longer serves a static index.html. Kept for
|
|
33
|
+
* consumers of the static-export API; it will be removed in the next major release.
|
|
34
|
+
*
|
|
18
35
|
* Rewrite the absolute `/_expo/` and `/assets/` references emitted by Expo's static
|
|
19
36
|
* web export so a single pre-built bundle works when mounted at any `basePath`.
|
|
20
37
|
*/
|
|
21
38
|
export declare const rewriteIndexHtml: (rawIndex: string, basePath: string) => string;
|
|
22
39
|
/**
|
|
40
|
+
* @deprecated `+html.tsx` now writes `window.__ADMIN_SPA_BASE__` into the server-rendered
|
|
41
|
+
* page. Kept for consumers of the static-export API; it will be removed in the next major.
|
|
42
|
+
*
|
|
23
43
|
* Inject `window.__ADMIN_SPA_BASE__` so the SPA can build absolute URLs (e.g.
|
|
24
44
|
* `${base}/app-config.json`) regardless of the current deep route. Inserted before
|
|
25
45
|
* `</head>` so it runs before the bundle.
|
|
26
46
|
*/
|
|
27
47
|
export declare const injectBaseGlobal: (html: string, basePath: string) => string;
|
|
28
48
|
/**
|
|
29
|
-
* Opt-in Express plugin that
|
|
30
|
-
* from the same Node process as a Terreno backend. Mounts at
|
|
49
|
+
* Opt-in Express plugin that server-renders the pre-built admin SPA (Expo Router
|
|
50
|
+
* `web.output: "server"` export) from the same Node process as a Terreno backend. Mounts at
|
|
51
|
+
* `/console` by default; `basePath` must equal the baseUrl the bundle was built with.
|
|
31
52
|
*
|
|
32
53
|
* @example
|
|
33
54
|
* ```typescript
|