@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/serve.js
CHANGED
|
@@ -4,16 +4,42 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AdminSpaServeApp = exports.injectBaseGlobal = exports.rewriteIndexHtml = void 0;
|
|
7
|
-
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
7
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
8
|
const api_1 = require("@terreno/api");
|
|
10
|
-
const express_1 =
|
|
9
|
+
const express_1 = require("expo-server/adapter/express");
|
|
10
|
+
const express_2 = __importDefault(require("express"));
|
|
11
11
|
const appConfig_1 = require("./appConfig");
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const gate_1 = require("./gate");
|
|
13
|
+
const routesManifest_1 = require("./routesManifest");
|
|
14
|
+
const serverContext_1 = require("./serverContext");
|
|
15
|
+
// Compiled (CommonJS) file lives at src/dist/serve.js; the export root (`client/` +
|
|
16
|
+
// `server/`) lives at admin-spa/dist. `__dirname` is the CommonJS module-directory global.
|
|
14
17
|
const DIST_DIR = node_path_1.default.resolve(__dirname, "../../dist");
|
|
15
18
|
const NO_STORE = "no-store";
|
|
19
|
+
/** Server-rendered pages and loader data can hold PHI: never cached, never shared. */
|
|
20
|
+
const NO_STORE_PRIVATE = "no-store, private";
|
|
21
|
+
/** API prefixes a server render may fetch, besides the admin API base path. */
|
|
22
|
+
const DEFAULT_SSR_API_PREFIXES = [
|
|
23
|
+
"/auth/me",
|
|
24
|
+
"/orgs",
|
|
25
|
+
"/jobs",
|
|
26
|
+
"/comms",
|
|
27
|
+
"/configuration",
|
|
28
|
+
"/rbac",
|
|
29
|
+
];
|
|
16
30
|
/**
|
|
31
|
+
* The default SSR allowlist: the admin API base, the fixed API roots, and the RBAC root, which
|
|
32
|
+
* mounts at the API root (`/internal/admin` puts it at `/internal/rbac`, as `useAdminRoles` does).
|
|
33
|
+
*/
|
|
34
|
+
const defaultSsrApiPrefixes = (adminApiBasePath = "/admin") => {
|
|
35
|
+
const rbacPrefix = `${adminApiBasePath.replace(/\/admin\/?$/, "")}/rbac`;
|
|
36
|
+
const prefixes = [adminApiBasePath, ...DEFAULT_SSR_API_PREFIXES];
|
|
37
|
+
return prefixes.includes(rbacPrefix) ? prefixes : [...prefixes, rbacPrefix];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* @deprecated The server-rendered build no longer serves a static index.html. Kept for
|
|
41
|
+
* consumers of the static-export API; it will be removed in the next major release.
|
|
42
|
+
*
|
|
17
43
|
* Rewrite the absolute `/_expo/` and `/assets/` references emitted by Expo's static
|
|
18
44
|
* web export so a single pre-built bundle works when mounted at any `basePath`.
|
|
19
45
|
*/
|
|
@@ -26,6 +52,9 @@ const rewriteIndexHtml = (rawIndex, basePath) => {
|
|
|
26
52
|
};
|
|
27
53
|
exports.rewriteIndexHtml = rewriteIndexHtml;
|
|
28
54
|
/**
|
|
55
|
+
* @deprecated `+html.tsx` now writes `window.__ADMIN_SPA_BASE__` into the server-rendered
|
|
56
|
+
* page. Kept for consumers of the static-export API; it will be removed in the next major.
|
|
57
|
+
*
|
|
29
58
|
* Inject `window.__ADMIN_SPA_BASE__` so the SPA can build absolute URLs (e.g.
|
|
30
59
|
* `${base}/app-config.json`) regardless of the current deep route. Inserted before
|
|
31
60
|
* `</head>` so it runs before the bundle.
|
|
@@ -39,8 +68,9 @@ const injectBaseGlobal = (html, basePath) => {
|
|
|
39
68
|
};
|
|
40
69
|
exports.injectBaseGlobal = injectBaseGlobal;
|
|
41
70
|
/**
|
|
42
|
-
* Opt-in Express plugin that
|
|
43
|
-
* from the same Node process as a Terreno backend. Mounts at
|
|
71
|
+
* Opt-in Express plugin that server-renders the pre-built admin SPA (Expo Router
|
|
72
|
+
* `web.output: "server"` export) from the same Node process as a Terreno backend. Mounts at
|
|
73
|
+
* `/console` by default; `basePath` must equal the baseUrl the bundle was built with.
|
|
44
74
|
*
|
|
45
75
|
* @example
|
|
46
76
|
* ```typescript
|
|
@@ -78,31 +108,96 @@ class AdminSpaServeApp {
|
|
|
78
108
|
api_1.logger.info(`Admin SPA dev-proxied to ${this.opts.devProxyTarget} at ${basePath}/`);
|
|
79
109
|
return;
|
|
80
110
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// 1. Hashed asset directories
|
|
111
|
+
const context = {
|
|
112
|
+
appConfig,
|
|
113
|
+
basePath,
|
|
114
|
+
internalOrigin: this.opts.internalOrigin ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`,
|
|
115
|
+
ssrApiPrefixes: this.opts.ssrApiPrefixes ?? defaultSsrApiPrefixes(appConfig.adminApiBasePath),
|
|
116
|
+
warn: (message, meta) => {
|
|
117
|
+
api_1.logger.warn(message, meta);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
(0, serverContext_1.publishAdminSpaServerContext)(context);
|
|
121
|
+
// 1. Hashed client asset directories: long cache, immutable.
|
|
122
|
+
const clientDir = node_path_1.default.join(distDir, "client");
|
|
92
123
|
const staticOpts = { immutable: true, maxAge: "365d" };
|
|
93
|
-
app.use(`${basePath}/_expo`,
|
|
94
|
-
app.use(`${basePath}/assets`,
|
|
95
|
-
// 2. Runtime config
|
|
124
|
+
app.use(`${basePath}/_expo`, express_2.default.static(node_path_1.default.join(clientDir, "_expo"), staticOpts));
|
|
125
|
+
app.use(`${basePath}/assets`, express_2.default.static(node_path_1.default.join(clientDir, "assets"), staticOpts));
|
|
126
|
+
// 2. Runtime config, never cached.
|
|
96
127
|
app.get(`${basePath}/app-config.json`, (_req, res) => {
|
|
97
128
|
res.set("Cache-Control", NO_STORE).json(appConfig);
|
|
98
129
|
});
|
|
99
|
-
// 3.
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
130
|
+
// 3. Server render. Without a server build, answer 503 instead of crashing the backend.
|
|
131
|
+
const serverDir = node_path_1.default.join(distDir, "server");
|
|
132
|
+
const rawManifest = (0, routesManifest_1.readRoutesManifest)(serverDir);
|
|
133
|
+
if (!rawManifest) {
|
|
134
|
+
api_1.logger.warn(`Admin SPA: no server build found at ${serverDir}. ` +
|
|
135
|
+
"Run `bun run --filter '@terreno/admin-spa' build:web` or set devProxyTarget.");
|
|
136
|
+
const unavailable = (_req, res) => {
|
|
137
|
+
res.status(503).set("Cache-Control", NO_STORE).type("text").send("Admin SPA unavailable");
|
|
138
|
+
};
|
|
139
|
+
app.get(basePath, unavailable);
|
|
140
|
+
app.get(`${basePath}/*splat`, unavailable);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const bakedBaseUrl = (0, routesManifest_1.bakedBaseUrlOf)(rawManifest);
|
|
144
|
+
if (bakedBaseUrl !== basePath) {
|
|
145
|
+
throw new Error(`Admin SPA basePath "${basePath}" does not match the bundle's baseUrl "${bakedBaseUrl}"`);
|
|
146
|
+
}
|
|
147
|
+
const manifest = (0, routesManifest_1.prepareRoutesManifest)(rawManifest, {
|
|
148
|
+
pageCacheControl: NO_STORE_PRIVATE,
|
|
149
|
+
});
|
|
150
|
+
const handler = (0, express_1.createRequestHandler)({ build: serverDir }, { getRoutesManifest: async () => manifest });
|
|
151
|
+
// Every server render runs in a request scope, so a render reads only its own request's
|
|
152
|
+
// data. Middleware that set a richer scope earlier (the auth gate) keeps it.
|
|
153
|
+
// A failed render or loader logs the route path and the error only: never the query string
|
|
154
|
+
// or response data, which can hold PHI.
|
|
155
|
+
const renderError = (error, req, res) => {
|
|
156
|
+
api_1.logger.error(`Admin SPA render failed for ${req.path}`, {
|
|
157
|
+
error: error instanceof Error ? error.message : String(error),
|
|
158
|
+
path: req.path,
|
|
159
|
+
});
|
|
160
|
+
if (res.headersSent) {
|
|
161
|
+
res.end();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
res.status(500).set("Cache-Control", NO_STORE_PRIVATE).type("text").send("Admin SPA error");
|
|
165
|
+
};
|
|
166
|
+
const mountPrefix = basePath === "/" ? "" : basePath;
|
|
167
|
+
// Runs Expo's handler in a request scope (the gate's, or a base one) and catches its errors
|
|
168
|
+
// here rather than in a root error middleware, which would also see the API's errors.
|
|
169
|
+
const renderPage = (req, res, next) => {
|
|
170
|
+
res.set("Cache-Control", NO_STORE_PRIVATE);
|
|
171
|
+
// Expo matches routes without the mount path; root loader requests have none to strip.
|
|
172
|
+
if (mountPrefix && (req.path === mountPrefix || req.path.startsWith(`${mountPrefix}/`))) {
|
|
173
|
+
const rest = req.url.slice(mountPrefix.length);
|
|
174
|
+
req.url = rest.startsWith("/") ? rest : `/${rest}`;
|
|
175
|
+
}
|
|
176
|
+
const run = () => {
|
|
177
|
+
handler(req, res, (error) => {
|
|
178
|
+
if (error) {
|
|
179
|
+
renderError(error, req, res);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
next();
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
if ((0, serverContext_1.getAdminSpaRequest)()) {
|
|
186
|
+
run();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
(0, serverContext_1.runInAdminSpaRequest)({ appConfig, basePath }, run);
|
|
103
190
|
};
|
|
104
|
-
|
|
105
|
-
|
|
191
|
+
// The gate decides access before anything renders, for pages under basePath and for loader
|
|
192
|
+
// data, which Expo 58's client fetches from the site root regardless of the baked baseUrl
|
|
193
|
+
// (D11 in the SSR plan).
|
|
194
|
+
app.use((0, gate_1.createAdminSpaGate)({
|
|
195
|
+
adminApiBasePath: appConfig.adminApiBasePath ?? "/admin",
|
|
196
|
+
appConfig,
|
|
197
|
+
basePath,
|
|
198
|
+
handler: renderPage,
|
|
199
|
+
internalOrigin: context.internalOrigin,
|
|
200
|
+
}));
|
|
106
201
|
api_1.logger.info(`Admin SPA mounted at ${basePath}/`);
|
|
107
202
|
}
|
|
108
203
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AdminSpaAppConfig } from "./appConfig";
|
|
2
|
+
/** What the serve plugin publishes for `render.js`, `+html.tsx`, and route loaders. */
|
|
3
|
+
export interface AdminSpaServerContext {
|
|
4
|
+
appConfig: AdminSpaAppConfig;
|
|
5
|
+
basePath: string;
|
|
6
|
+
/** Origin loopback SSR fetches go to. Never built from `Host` or `X-Forwarded-*`. */
|
|
7
|
+
internalOrigin: string;
|
|
8
|
+
/** API path prefixes a server render may fetch; anything else is rejected. */
|
|
9
|
+
ssrApiPrefixes: string[];
|
|
10
|
+
/**
|
|
11
|
+
* The host's warning logger (`logger.warn` from `@terreno/api`). The render bundle stubs
|
|
12
|
+
* `@terreno/api`, so server code reaches the host's log pipeline through this.
|
|
13
|
+
*/
|
|
14
|
+
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* What one request's server render may read: set by Express before the Expo handler runs,
|
|
18
|
+
* visible to that request's render and loaders only.
|
|
19
|
+
*/
|
|
20
|
+
export interface AdminSpaRequestScope {
|
|
21
|
+
appConfig: AdminSpaAppConfig;
|
|
22
|
+
basePath: string;
|
|
23
|
+
/** The Better Auth session snapshot, or null for an anonymous visitor. Set by the gate. */
|
|
24
|
+
session?: AdminSpaSessionSnapshot | null;
|
|
25
|
+
/** The visitor's `GET /auth/me` body, or null when anonymous. Set by the gate. */
|
|
26
|
+
profile?: unknown;
|
|
27
|
+
/** The `GET {adminApiBasePath}/config` body for an admin, else null. Set by the gate. */
|
|
28
|
+
adminConfig?: unknown;
|
|
29
|
+
/** Status of the admin config read (200 admin, 403 forbidden), or null when not read. */
|
|
30
|
+
adminConfigStatus?: number | null;
|
|
31
|
+
}
|
|
32
|
+
/** The Better Auth session as the client's session source reads it. */
|
|
33
|
+
export interface AdminSpaSessionSnapshot {
|
|
34
|
+
userId: string;
|
|
35
|
+
user: unknown;
|
|
36
|
+
}
|
|
37
|
+
/** Publish the server context. One admin SPA per process: a later call replaces it. */
|
|
38
|
+
export declare const publishAdminSpaServerContext: (context: AdminSpaServerContext) => void;
|
|
39
|
+
/** Read the published server context, or undefined before the plugin registers. */
|
|
40
|
+
export declare const getAdminSpaServerContext: () => AdminSpaServerContext | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Warn from server render code through the host's logger, or `console.warn` before the plugin
|
|
43
|
+
* has published one. Callers pass paths and reasons only: never a query string, a cookie or a
|
|
44
|
+
* response body.
|
|
45
|
+
*/
|
|
46
|
+
export declare const warnAdminSpaServer: (message: string, meta?: Record<string, unknown>) => void;
|
|
47
|
+
/**
|
|
48
|
+
* True while rendering on the server: there is no browser `window`, and the serve plugin has
|
|
49
|
+
* published its context in this process. A server render reads what it was given and never
|
|
50
|
+
* starts watchers, sockets or requests of its own.
|
|
51
|
+
*/
|
|
52
|
+
export declare const isAdminSpaServerRender: () => boolean;
|
|
53
|
+
/** Run `fn` with `scope` as the current request scope (server only). */
|
|
54
|
+
export declare const runInAdminSpaRequest: <T>(scope: AdminSpaRequestScope, fn: () => T) => T;
|
|
55
|
+
/** The current request's scope, or undefined outside a request (and always in the browser). */
|
|
56
|
+
export declare const getAdminSpaRequest: () => AdminSpaRequestScope | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Serialize a value for embedding in HTML (an inline `<script>` or a JSON script blob): JSON
|
|
59
|
+
* with every character that could close the script or break a JS string literal escaped.
|
|
60
|
+
*/
|
|
61
|
+
export declare const serializeForHtml: (value: unknown) => string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.serializeForHtml = exports.getAdminSpaRequest = exports.runInAdminSpaRequest = exports.isAdminSpaServerRender = exports.warnAdminSpaServer = exports.getAdminSpaServerContext = exports.publishAdminSpaServerContext = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Key of the process global the serve plugin publishes for the server render. The Expo
|
|
6
|
+
* `render.js` bundle and its loaders run in the same process as Express but are bundled
|
|
7
|
+
* separately, so a process global is the one place both can read. `Symbol.for` keeps the
|
|
8
|
+
* key identical across the two bundles.
|
|
9
|
+
*/
|
|
10
|
+
const ADMIN_SPA_SERVER_CONTEXT_KEY = Symbol.for("terreno.adminSpa.server");
|
|
11
|
+
/** Key of the one per-request `AsyncLocalStorage`, shared by every bundle in the process. */
|
|
12
|
+
const ADMIN_SPA_REQUEST_STORE_KEY = Symbol.for("terreno.adminSpa.requestStore");
|
|
13
|
+
/** Publish the server context. One admin SPA per process: a later call replaces it. */
|
|
14
|
+
const publishAdminSpaServerContext = (context) => {
|
|
15
|
+
globalThis[ADMIN_SPA_SERVER_CONTEXT_KEY] = context;
|
|
16
|
+
};
|
|
17
|
+
exports.publishAdminSpaServerContext = publishAdminSpaServerContext;
|
|
18
|
+
/** Read the published server context, or undefined before the plugin registers. */
|
|
19
|
+
const getAdminSpaServerContext = () => {
|
|
20
|
+
return globalThis[ADMIN_SPA_SERVER_CONTEXT_KEY];
|
|
21
|
+
};
|
|
22
|
+
exports.getAdminSpaServerContext = getAdminSpaServerContext;
|
|
23
|
+
/**
|
|
24
|
+
* Warn from server render code through the host's logger, or `console.warn` before the plugin
|
|
25
|
+
* has published one. Callers pass paths and reasons only: never a query string, a cookie or a
|
|
26
|
+
* response body.
|
|
27
|
+
*/
|
|
28
|
+
const warnAdminSpaServer = (message, meta) => {
|
|
29
|
+
const warn = (0, exports.getAdminSpaServerContext)()?.warn;
|
|
30
|
+
if (warn) {
|
|
31
|
+
warn(message, meta);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (meta) {
|
|
35
|
+
console.warn(message, meta);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
console.warn(message);
|
|
39
|
+
};
|
|
40
|
+
exports.warnAdminSpaServer = warnAdminSpaServer;
|
|
41
|
+
/**
|
|
42
|
+
* True while rendering on the server: there is no browser `window`, and the serve plugin has
|
|
43
|
+
* published its context in this process. A server render reads what it was given and never
|
|
44
|
+
* starts watchers, sockets or requests of its own.
|
|
45
|
+
*/
|
|
46
|
+
const isAdminSpaServerRender = () => {
|
|
47
|
+
return !("window" in globalThis) && (0, exports.getAdminSpaServerContext)() !== undefined;
|
|
48
|
+
};
|
|
49
|
+
exports.isAdminSpaServerRender = isAdminSpaServerRender;
|
|
50
|
+
/**
|
|
51
|
+
* The process-wide request store, created on first use. `process.getBuiltinModule` keeps
|
|
52
|
+
* `node:async_hooks` out of the static import graph, because this module is also bundled for
|
|
53
|
+
* the browser, where there is no request store.
|
|
54
|
+
*/
|
|
55
|
+
const requestStore = () => {
|
|
56
|
+
const global = globalThis;
|
|
57
|
+
const existing = global[ADMIN_SPA_REQUEST_STORE_KEY];
|
|
58
|
+
if (existing) {
|
|
59
|
+
return existing;
|
|
60
|
+
}
|
|
61
|
+
const asyncHooks = process.getBuiltinModule("node:async_hooks");
|
|
62
|
+
const store = new asyncHooks.AsyncLocalStorage();
|
|
63
|
+
global[ADMIN_SPA_REQUEST_STORE_KEY] = store;
|
|
64
|
+
return store;
|
|
65
|
+
};
|
|
66
|
+
/** Run `fn` with `scope` as the current request scope (server only). */
|
|
67
|
+
const runInAdminSpaRequest = (scope, fn) => {
|
|
68
|
+
return requestStore().run(scope, fn);
|
|
69
|
+
};
|
|
70
|
+
exports.runInAdminSpaRequest = runInAdminSpaRequest;
|
|
71
|
+
/** The current request's scope, or undefined outside a request (and always in the browser). */
|
|
72
|
+
const getAdminSpaRequest = () => {
|
|
73
|
+
return globalThis[ADMIN_SPA_REQUEST_STORE_KEY]?.getStore();
|
|
74
|
+
};
|
|
75
|
+
exports.getAdminSpaRequest = getAdminSpaRequest;
|
|
76
|
+
/**
|
|
77
|
+
* Serialize a value for embedding in HTML (an inline `<script>` or a JSON script blob): JSON
|
|
78
|
+
* with every character that could close the script or break a JS string literal escaped.
|
|
79
|
+
*/
|
|
80
|
+
const serializeForHtml = (value) => {
|
|
81
|
+
return JSON.stringify(value)
|
|
82
|
+
.replaceAll("<", "\\u003c")
|
|
83
|
+
.replaceAll(">", "\\u003e")
|
|
84
|
+
.replaceAll("&", "\\u0026")
|
|
85
|
+
.replaceAll("\u2028", "\\u2028")
|
|
86
|
+
.replaceAll("\u2029", "\\u2029");
|
|
87
|
+
};
|
|
88
|
+
exports.serializeForHtml = serializeForHtml;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSignedInOnlyRoute = void 0;
|
|
4
|
+
/** Routes any signed-in user may open; they skip the admin check and admin bootstrapping. */
|
|
5
|
+
const SIGNED_IN_ONLY_ROUTES = ["/oauth/consent"];
|
|
6
|
+
/** Whether a route path (`/oauth/consent`, trailing slashes ignored) is signed-in-only. */
|
|
7
|
+
const isSignedInOnlyRoute = (pathname) => {
|
|
8
|
+
return SIGNED_IN_ONLY_ROUTES.includes(pathname.replace(/\/+$/, "") || "/");
|
|
9
|
+
};
|
|
10
|
+
exports.isSignedInOnlyRoute = isSignedInOnlyRoute;
|