@antelopejs/dms-frontend 0.9.3 → 0.10.1
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/README.md +46 -29
- package/dist/commands/build-action.d.ts +3 -0
- package/dist/commands/build-action.js +77 -8
- package/dist/commands/build.js +7 -0
- package/dist/commands/clean-action.js +1 -1
- package/dist/commands/clean.js +1 -1
- package/dist/commands/dev-action.js +4 -3
- package/dist/commands/dev.js +1 -1
- package/dist/commands/prepare-action.js +1 -1
- package/dist/commands/start-action.js +3 -24
- package/dist/commands/workspace-task.js +2 -1
- package/dist/config.js +1 -0
- package/dist/derived-outputs.d.ts +1 -0
- package/dist/derived-outputs.js +6 -4
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- package/dist/help.js +1 -1
- package/dist/index.js +2 -1
- package/dist/internal/built-workspace.d.ts +4 -0
- package/dist/internal/built-workspace.js +43 -0
- package/dist/internal/ensure.d.ts +1 -0
- package/dist/internal/ensure.js +8 -0
- package/dist/internal/locale-catalogs.d.ts +8 -3
- package/dist/internal/locale-catalogs.js +16 -6
- package/dist/internal/manifest-decoder.d.ts +7 -0
- package/dist/internal/manifest-decoder.js +81 -0
- package/dist/internal/own-release.d.ts +5 -0
- package/dist/internal/own-release.js +20 -0
- package/dist/internal/plain-object.d.ts +1 -0
- package/dist/internal/plain-object.js +6 -0
- package/dist/internal/prepared-workspace.d.ts +12 -0
- package/dist/internal/prepared-workspace.js +74 -0
- package/dist/internal/text-list.d.ts +1 -0
- package/dist/internal/text-list.js +10 -0
- package/dist/internal/vite-manifest.d.ts +6 -0
- package/dist/internal/vite-manifest.js +24 -0
- package/dist/layer-watch.js +25 -24
- package/dist/layers.d.ts +1 -1
- package/dist/layers.js +3 -1
- package/dist/locale-files.d.ts +6 -3
- package/dist/locale-files.js +15 -9
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +46 -32
- package/dist/materialize.js +6 -2
- package/dist/module-set.js +2 -1
- package/dist/node-options.d.ts +1 -0
- package/dist/node-options.js +8 -0
- package/dist/output.d.ts +15 -9
- package/dist/output.js +30 -18
- package/dist/ports.d.ts +3 -1
- package/dist/ports.js +1 -1
- package/dist/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.d.ts +5 -1
- package/dist/project-modules.js +23 -15
- package/dist/renderer-range.js +5 -3
- package/dist/server-process.js +1 -1
- package/dist/verify-source-build.js +10 -2
- package/dist/verify-source-client-bundle.d.ts +1 -0
- package/dist/verify-source-client-bundle.js +30 -0
- package/dist/verify-source-runner.js +36 -94
- package/dist/verify-source-sources.js +49 -11
- package/dist/verify-source-workspace.d.ts +2 -0
- package/dist/verify-source-workspace.js +111 -0
- package/dist/workspace-setup.d.ts +1 -0
- package/dist/workspace-setup.js +4 -3
- package/package.json +11 -7
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +2 -9
- package/templates/vue/backend-boundary.mjs +132 -0
- package/templates/vue/client-chunking.mjs +2 -0
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +29 -3
- package/templates/vue/frontend-build.ts +4 -3
- package/templates/vue/frontend-module.ts +1 -0
- package/templates/vue/frontend-runtime/module-registration.ts +2 -2
- package/templates/vue/frontend-runtime/state.ts +2 -2
- package/templates/vue/frontend-runtime/types.ts +8 -2
- package/templates/vue/head-order.mjs +10 -2
- package/templates/vue/icon-api.mjs +2 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/server/auth/backend.mjs +42 -5
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +11 -7
- package/templates/vue/server/auth/session.mjs +86 -4
- package/templates/vue/server/auth-declarations.mjs +46 -0
- package/templates/vue/server/auth-hooks.mjs +55 -7
- package/templates/vue/server/backend-proxies.mjs +78 -18
- package/templates/vue/server/backend-request.mjs +61 -0
- package/templates/vue/server/backend-response.mjs +23 -6
- package/templates/vue/server/built-files.mjs +7 -0
- package/templates/vue/server/cli-report.mjs +75 -12
- package/templates/vue/server/client-error.mjs +5 -0
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +40 -10
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email-bundle-watch.mjs +9 -3
- package/templates/vue/server/email.mjs +14 -6
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +221 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +93 -16
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +76 -3
- package/templates/vue/server/module-event.mjs +118 -25
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +151 -96
- package/templates/vue/server/module-server.mjs +121 -40
- package/templates/vue/server/module-testing.mjs +36 -12
- package/templates/vue/server/page-props.mjs +94 -3
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +35 -12
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +12 -2
- package/templates/vue/server/proxy-redirects.mjs +21 -5
- package/templates/vue/server/proxy-request.mjs +58 -0
- package/templates/vue/server/proxy-transfers.mjs +44 -10
- package/templates/vue/server/public-endpoints.mjs +139 -28
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +24 -1
- package/templates/vue/server/request-routing.mjs +32 -10
- package/templates/vue/server/request-target.mjs +30 -11
- package/templates/vue/server/route-rules.mjs +48 -18
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +35 -7
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +45 -7
- package/templates/vue/server/web-body.mjs +5 -1
- package/templates/vue/server-module.ts +12 -2
- package/templates/vue/server.mjs +162 -157
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +2 -2
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-request-state.mjs +2 -1
- package/templates/vue/vite.config.ts +9 -0
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { allowedEstablishEndpoints } from "./auth/establish-endpoints.mjs";
|
|
4
4
|
import { serverAuth } from "./auth-hooks.mjs";
|
|
5
5
|
import { answerModuleRoute } from "./module-event.mjs";
|
|
6
|
+
import { isNonNullObject, isRecord, isString } from "./type-guards.mjs";
|
|
6
7
|
import {
|
|
7
8
|
matchModuleRoute,
|
|
8
9
|
mayHaveModuleRoute,
|
|
@@ -16,7 +17,30 @@ import {
|
|
|
16
17
|
} from "./public-endpoints.mjs";
|
|
17
18
|
import { reachesBackend } from "./request-routing.mjs";
|
|
18
19
|
|
|
20
|
+
import { frontendRegistryOf, serverOptionsOf } from "./generated-files.mjs";
|
|
19
21
|
import { publicOptionsOf } from "./public-options.mjs";
|
|
22
|
+
/** @typedef {import("./module-route-types.mjs").ServerModuleDeclaration} ServerModuleDeclaration */
|
|
23
|
+
/**
|
|
24
|
+
* What a handler of a module reads besides its request.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {object} ModuleContext
|
|
27
|
+
* @property {{ id: string, options: unknown, privateOptions: unknown }} module
|
|
28
|
+
* @property {{ authEstablishEndpoints: readonly string[] }} app
|
|
29
|
+
* @property {string | undefined} authOwner The module that owns sign-in
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* A deployment's data, which the server hands to a set of server modules.
|
|
33
|
+
*
|
|
34
|
+
* @typedef {object} Deployment
|
|
35
|
+
* @property {Record<string, unknown>} [publicOptions] Each module's public options by id
|
|
36
|
+
* @property {Record<string, unknown>} [privateOptions] Each module's private options by id
|
|
37
|
+
* @property {() => readonly string[]} [authEstablishEndpoints]
|
|
38
|
+
* @property {import("./public-endpoints.mjs").PublicEndpoint[]} [publicEndpoints]
|
|
39
|
+
* @property {import("./backend-proxies.mjs").ManifestProxy[]} [backendProxies]
|
|
40
|
+
* @property {(message: string) => void} warn
|
|
41
|
+
*/
|
|
42
|
+
/** @typedef {ReturnType<typeof createServerModules>} ServerModules */
|
|
43
|
+
|
|
20
44
|
const WORKSPACE_ROOT = new URL("../", import.meta.url);
|
|
21
45
|
const SOURCE_LOADER = "/server-modules.generated.ts";
|
|
22
46
|
const BUILT_LOADER = new URL(
|
|
@@ -32,11 +56,20 @@ const SERVER_OPTIONS_FILE = new URL(
|
|
|
32
56
|
WORKSPACE_ROOT,
|
|
33
57
|
);
|
|
34
58
|
|
|
59
|
+
/** @type {Promise<ServerModuleDeclaration[]> | undefined} */
|
|
35
60
|
let builtDeclarations;
|
|
61
|
+
/** @type {ServerModuleDeclaration[]} */
|
|
36
62
|
let lastDeclarations = [];
|
|
63
|
+
/** @type {string | undefined} */
|
|
37
64
|
let lastLoadError;
|
|
65
|
+
/** @type {WeakMap<ServerModuleDeclaration[], ServerModules>} */
|
|
38
66
|
const resolved = new WeakMap();
|
|
39
67
|
|
|
68
|
+
/**
|
|
69
|
+
* @param {URL} url
|
|
70
|
+
* @param {unknown} fallback
|
|
71
|
+
* @returns {unknown} The file's JSON, the fallback when it cannot be read
|
|
72
|
+
*/
|
|
40
73
|
function readJsonFile(url, fallback) {
|
|
41
74
|
try {
|
|
42
75
|
return JSON.parse(readFileSync(url, "utf8"));
|
|
@@ -47,31 +80,58 @@ function readJsonFile(url, fallback) {
|
|
|
47
80
|
|
|
48
81
|
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
49
82
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The object holding `path`'s last key, and that key, when every key up to it
|
|
85
|
+
* is own.
|
|
86
|
+
*
|
|
87
|
+
* @param {Record<string, unknown>} target
|
|
88
|
+
* @param {string[]} path
|
|
89
|
+
* @returns {{ holder: Record<string, unknown>, key: string } | undefined}
|
|
90
|
+
*/
|
|
91
|
+
function slotOf(target, path) {
|
|
92
|
+
const key = path.at(-1);
|
|
93
|
+
if (key === undefined || UNSAFE_KEYS.has(key)) return undefined;
|
|
52
94
|
let node = target;
|
|
53
|
-
for (const
|
|
54
|
-
if (UNSAFE_KEYS.has(
|
|
55
|
-
|
|
56
|
-
if (
|
|
95
|
+
for (const step of path.slice(0, -1)) {
|
|
96
|
+
if (UNSAFE_KEYS.has(step) || !Object.hasOwn(node, step)) return undefined;
|
|
97
|
+
const next = node[step];
|
|
98
|
+
if (!isRecord(next)) return undefined;
|
|
99
|
+
node = next;
|
|
57
100
|
}
|
|
58
|
-
return
|
|
101
|
+
return { holder: node, key };
|
|
59
102
|
}
|
|
60
103
|
|
|
61
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Sets `value` at `path` when the object holding it exists but lacks it.
|
|
106
|
+
*
|
|
107
|
+
* @param {Record<string, unknown>} target
|
|
108
|
+
* @param {string[]} path
|
|
109
|
+
* @param {string} value
|
|
110
|
+
*/
|
|
62
111
|
function fillPath(target, path, value) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
holder[key]
|
|
112
|
+
const slot = slotOf(target, path);
|
|
113
|
+
if (
|
|
114
|
+
slot &&
|
|
115
|
+
(slot.holder[slot.key] === undefined || slot.holder[slot.key] === "")
|
|
116
|
+
)
|
|
117
|
+
slot.holder[slot.key] = value;
|
|
67
118
|
}
|
|
68
119
|
|
|
69
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Sets `value` at `path` only where the build left an empty string.
|
|
122
|
+
*
|
|
123
|
+
* @param {Record<string, unknown>} target
|
|
124
|
+
* @param {string[]} path
|
|
125
|
+
* @param {string} value
|
|
126
|
+
*/
|
|
70
127
|
function fillEmptied(target, path, value) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
holder
|
|
128
|
+
const slot = slotOf(target, path);
|
|
129
|
+
if (
|
|
130
|
+
slot &&
|
|
131
|
+
Object.hasOwn(slot.holder, slot.key) &&
|
|
132
|
+
slot.holder[slot.key] === ""
|
|
133
|
+
)
|
|
134
|
+
slot.holder[slot.key] = value;
|
|
75
135
|
}
|
|
76
136
|
|
|
77
137
|
/**
|
|
@@ -79,10 +139,10 @@ function fillEmptied(target, path, value) {
|
|
|
79
139
|
* the environment variable of a secret (`DMS_HTML_RENDER_SECRET`,
|
|
80
140
|
* `DMS_OAUTH_RELAY_SECRET`) only fills in a section that carries none.
|
|
81
141
|
*
|
|
82
|
-
* @param modules The private options by module id, as the manifest published
|
|
142
|
+
* @param {Record<string, Record<string, unknown>>} modules The private options by module id, as the manifest published
|
|
83
143
|
* them
|
|
84
|
-
* @param environment The variables by the path of the secret they stand for
|
|
85
|
-
* @param env The process environment
|
|
144
|
+
* @param {Record<string, string[]>} environment The variables by the path of the secret they stand for
|
|
145
|
+
* @param {NodeJS.ProcessEnv} env The process environment
|
|
86
146
|
*/
|
|
87
147
|
export function privateOptionsWithFallbacks(modules, environment, env) {
|
|
88
148
|
for (const options of Object.values(modules))
|
|
@@ -91,10 +151,16 @@ export function privateOptionsWithFallbacks(modules, environment, env) {
|
|
|
91
151
|
return modules;
|
|
92
152
|
}
|
|
93
153
|
|
|
94
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Each string leaf of `value`, array items included, with its path.
|
|
156
|
+
*
|
|
157
|
+
* @param {unknown} value
|
|
158
|
+
* @param {string[]} [path]
|
|
159
|
+
* @returns {Generator<[string[], string]>}
|
|
160
|
+
*/
|
|
95
161
|
function* stringLeaves(value, path = []) {
|
|
96
|
-
if (
|
|
97
|
-
else if (
|
|
162
|
+
if (isString(value)) yield [path, value];
|
|
163
|
+
else if (isNonNullObject(value))
|
|
98
164
|
for (const [key, child] of Object.entries(value))
|
|
99
165
|
yield* stringLeaves(child, [...path, key]);
|
|
100
166
|
}
|
|
@@ -107,19 +173,20 @@ function* stringLeaves(value, path = []) {
|
|
|
107
173
|
* a value the build emptied is filled, array items included: a value the
|
|
108
174
|
* manifest carries wins, and no option or section is added or replaced.
|
|
109
175
|
*
|
|
110
|
-
* @param modules The private options by module id
|
|
111
|
-
* @param json The variable's value; nothing is filled when unset
|
|
112
|
-
* @param warn Reports a value that is not a JSON object, never its content
|
|
176
|
+
* @param {Record<string, Record<string, unknown>>} modules The private options by module id
|
|
177
|
+
* @param {string | undefined} json The variable's value; nothing is filled when unset
|
|
178
|
+
* @param {(message: string) => void} warn Reports a value that is not a JSON object, never its content
|
|
113
179
|
*/
|
|
114
180
|
export function privateOptionsFromJson(modules, json, warn) {
|
|
115
181
|
if (!json) return modules;
|
|
182
|
+
/** @type {unknown} */
|
|
116
183
|
let given;
|
|
117
184
|
try {
|
|
118
185
|
given = JSON.parse(json);
|
|
119
186
|
} catch {
|
|
120
187
|
given = undefined;
|
|
121
188
|
}
|
|
122
|
-
if (
|
|
189
|
+
if (!isNonNullObject(given) || Array.isArray(given)) {
|
|
123
190
|
warn("DMS_PRIVATE_OPTIONS is not a JSON object of options by module id");
|
|
124
191
|
return modules;
|
|
125
192
|
}
|
|
@@ -131,10 +198,10 @@ export function privateOptionsFromJson(modules, json, warn) {
|
|
|
131
198
|
return modules;
|
|
132
199
|
}
|
|
133
200
|
|
|
201
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
134
202
|
function readPrivateOptions(env = process.env) {
|
|
135
|
-
const { modules
|
|
136
|
-
SERVER_OPTIONS_FILE,
|
|
137
|
-
{},
|
|
203
|
+
const { modules, environment } = serverOptionsOf(
|
|
204
|
+
readJsonFile(SERVER_OPTIONS_FILE, {}),
|
|
138
205
|
);
|
|
139
206
|
return privateOptionsFromJson(
|
|
140
207
|
privateOptionsWithFallbacks(modules, environment, env),
|
|
@@ -144,14 +211,22 @@ function readPrivateOptions(env = process.env) {
|
|
|
144
211
|
}
|
|
145
212
|
|
|
146
213
|
function readPublicOptions() {
|
|
147
|
-
return publicOptionsOf(
|
|
214
|
+
return publicOptionsOf(
|
|
215
|
+
frontendRegistryOf(readJsonFile(REGISTRY_FILE, { modules: [] })),
|
|
216
|
+
);
|
|
148
217
|
}
|
|
149
218
|
|
|
219
|
+
/**
|
|
220
|
+
* @param {import("vite").ViteDevServer | undefined} devServer
|
|
221
|
+
* @returns {Promise<ServerModuleDeclaration[]>}
|
|
222
|
+
*/
|
|
150
223
|
async function loadDeclarations(devServer) {
|
|
151
224
|
if (devServer)
|
|
225
|
+
// oxlint-disable-next-line typescript/no-unsafe-return -- `ssrLoadModule` types every module `Record<string, any>`; the loader is the generated `server-modules.generated.ts`, which exports `serverModules`.
|
|
152
226
|
return (await devServer.ssrLoadModule(SOURCE_LOADER)).serverModules;
|
|
153
227
|
if (!existsSync(fileURLToPath(BUILT_LOADER))) return [];
|
|
154
228
|
builtDeclarations ??= import(BUILT_LOADER.href).then(
|
|
229
|
+
// oxlint-disable-next-line typescript/no-unsafe-return -- the build of that same generated module is imported by a URL computed at run time, so it is untyped.
|
|
155
230
|
(loaded) => loaded.serverModules,
|
|
156
231
|
);
|
|
157
232
|
return builtDeclarations;
|
|
@@ -161,6 +236,8 @@ async function loadDeclarations(devServer) {
|
|
|
161
236
|
* The declarations to serve. A module entry that fails to load in development
|
|
162
237
|
* (a syntax error being typed) is reported once, and the last routes that
|
|
163
238
|
* loaded keep answering meanwhile.
|
|
239
|
+
*
|
|
240
|
+
* @param {import("vite").ViteDevServer | undefined} devServer
|
|
164
241
|
*/
|
|
165
242
|
async function currentDeclarations(devServer) {
|
|
166
243
|
try {
|
|
@@ -181,8 +258,8 @@ async function currentDeclarations(devServer) {
|
|
|
181
258
|
*
|
|
182
259
|
* `authOwner` is the module whose sealed cookies are the `dms_*` ones.
|
|
183
260
|
*
|
|
184
|
-
* @param declarations `{ id, priority, setup }` per module with a dms.server.ts
|
|
185
|
-
* @param deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
|
|
261
|
+
* @param {readonly ServerModuleDeclaration[]} declarations `{ id, priority, setup }` per module with a dms.server.ts
|
|
262
|
+
* @param {Deployment} deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
|
|
186
263
|
* publicEndpoints, backendProxies, warn }`: each module's options by id,
|
|
187
264
|
* the session-opening endpoints as a function (the environment can change
|
|
188
265
|
* them), the public endpoints, the backend proxies the manifest declares
|
|
@@ -198,7 +275,7 @@ export function createServerModules(declarations, deployment) {
|
|
|
198
275
|
deployment.publicEndpoints ?? [],
|
|
199
276
|
deployment.backendProxies ?? [],
|
|
200
277
|
);
|
|
201
|
-
const context = (id) => ({
|
|
278
|
+
const context = (/** @type {string} */ id) => ({
|
|
202
279
|
module: {
|
|
203
280
|
id,
|
|
204
281
|
options: deployment.publicOptions?.[id],
|
|
@@ -216,7 +293,11 @@ export function createServerModules(declarations, deployment) {
|
|
|
216
293
|
};
|
|
217
294
|
}
|
|
218
295
|
|
|
219
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* Resolved once per set of declarations: in development, once per edit.
|
|
298
|
+
*
|
|
299
|
+
* @param {ServerModuleDeclaration[]} declarations
|
|
300
|
+
*/
|
|
220
301
|
function serverModules(declarations) {
|
|
221
302
|
let modules = resolved.get(declarations);
|
|
222
303
|
if (!modules) {
|
|
@@ -245,7 +326,7 @@ function serverModules(declarations) {
|
|
|
245
326
|
* next request; in production from the bundle `ajs dms build` makes
|
|
246
327
|
* (`dist/server-modules`). A workspace built before the feature has none.
|
|
247
328
|
*
|
|
248
|
-
* @param devServer Vite in development, `undefined` in production
|
|
329
|
+
* @param {import("vite").ViteDevServer | undefined} devServer Vite in development, `undefined` in production
|
|
249
330
|
*/
|
|
250
331
|
export async function loadServerModules(devServer) {
|
|
251
332
|
return serverModules(await currentDeclarations(devServer));
|
|
@@ -256,10 +337,10 @@ export async function loadServerModules(devServer) {
|
|
|
256
337
|
* module route serves is the module's for every method: another method is
|
|
257
338
|
* answered `405`.
|
|
258
339
|
*
|
|
259
|
-
* @param request The Node request
|
|
260
|
-
* @param response The Node response
|
|
261
|
-
* @param pathname The request's normalized path
|
|
262
|
-
* @param modules What `loadServerModules` or `createServerModules` returned
|
|
340
|
+
* @param {import("./request-target.mjs").ServerRequest} request The Node request
|
|
341
|
+
* @param {import("node:http").ServerResponse} response The Node response
|
|
342
|
+
* @param {string} pathname The request's normalized path
|
|
343
|
+
* @param {ServerModules} modules What `loadServerModules` or `createServerModules` returned
|
|
263
344
|
* @returns Whether a module route answered
|
|
264
345
|
*/
|
|
265
346
|
export async function handleModuleRoute(request, response, pathname, modules) {
|
|
@@ -2,10 +2,18 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
|
|
3
3
|
import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
|
|
4
4
|
import { createServerModules, handleModuleRoute } from "./module-server.mjs";
|
|
5
|
+
import { failureStatus, writeBadRequest } from "./request-failure.mjs";
|
|
6
|
+
import { isServerRequest } from "./request-target.mjs";
|
|
7
|
+
import { isString } from "./type-guards.mjs";
|
|
5
8
|
|
|
6
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* What the frontend server answers an API request whose handler failed.
|
|
11
|
+
*
|
|
12
|
+
* @param {unknown} error
|
|
13
|
+
* @param {import("node:http").ServerResponse} response
|
|
14
|
+
*/
|
|
7
15
|
function writeFailure(error, response) {
|
|
8
|
-
const status =
|
|
16
|
+
const status = failureStatus(error);
|
|
9
17
|
const body =
|
|
10
18
|
error instanceof UpstreamError || error instanceof RequestBodyError
|
|
11
19
|
? { message: error.message }
|
|
@@ -26,9 +34,9 @@ function writeFailure(error, response) {
|
|
|
26
34
|
* `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the environment, as in
|
|
27
35
|
* production.
|
|
28
36
|
*
|
|
29
|
-
* @param declarations `{ id, priority?, setup }` per module, `setup` being
|
|
37
|
+
* @param {readonly import("./module-server.mjs").ServerModuleDeclaration[]} declarations `{ id, priority?, setup }` per module, `setup` being
|
|
30
38
|
* the default export of its `dms.server.ts`
|
|
31
|
-
* @param deployment `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
|
|
39
|
+
* @param {Omit<import("./module-server.mjs").Deployment, "warn" | "authEstablishEndpoints"> & { authEstablishEndpoints?: readonly string[] }} [deployment] `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
|
|
32
40
|
* publicEndpoints?, backendProxies? }`, options by module id, and the
|
|
33
41
|
* manifest's backend proxies (`{ module, path, auth? }`), which decide
|
|
34
42
|
* the backend redirects relayed as frontend paths. The private options are
|
|
@@ -37,6 +45,7 @@ function writeFailure(error, response) {
|
|
|
37
45
|
* @returns `{ url, modules, warnings, close() }`
|
|
38
46
|
*/
|
|
39
47
|
export async function serveModuleRoutes(declarations, deployment = {}) {
|
|
48
|
+
/** @type {string[]} */
|
|
40
49
|
const warnings = [];
|
|
41
50
|
const endpoints = deployment.authEstablishEndpoints ?? [];
|
|
42
51
|
const modules = createServerModules(declarations, {
|
|
@@ -45,23 +54,38 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
|
|
|
45
54
|
warn: (message) => warnings.push(message),
|
|
46
55
|
});
|
|
47
56
|
const server = createServer((request, response) => {
|
|
57
|
+
if (!isServerRequest(request)) {
|
|
58
|
+
writeBadRequest(response);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
48
61
|
const { pathname } = new URL(request.url, "http://frontend.local");
|
|
49
62
|
handleModuleRoute(request, response, pathname, modules)
|
|
50
63
|
.then((answered) => {
|
|
51
64
|
if (!answered) response.writeHead(404).end();
|
|
52
65
|
})
|
|
53
|
-
.catch(
|
|
66
|
+
.catch(
|
|
67
|
+
/** @param {unknown} error */ (error) => writeFailure(error, response),
|
|
68
|
+
);
|
|
54
69
|
});
|
|
55
|
-
|
|
56
|
-
const
|
|
70
|
+
/** @type {Promise<void>} */
|
|
71
|
+
const listening = new Promise((settle) =>
|
|
72
|
+
server.listen(0, "127.0.0.1", settle),
|
|
73
|
+
);
|
|
74
|
+
await listening;
|
|
75
|
+
const address = server.address();
|
|
76
|
+
if (address === null || isString(address))
|
|
77
|
+
throw new TypeError("The module test server is not listening on a port");
|
|
78
|
+
const { port } = address;
|
|
79
|
+
/** @returns {Promise<void>} */
|
|
80
|
+
const close = () =>
|
|
81
|
+
new Promise((settle) => {
|
|
82
|
+
server.closeAllConnections();
|
|
83
|
+
server.close(() => settle());
|
|
84
|
+
});
|
|
57
85
|
return {
|
|
58
86
|
url: `http://127.0.0.1:${port}`,
|
|
59
87
|
modules,
|
|
60
88
|
warnings,
|
|
61
|
-
close
|
|
62
|
-
new Promise((settle) => {
|
|
63
|
-
server.closeAllConnections();
|
|
64
|
-
server.close(() => settle());
|
|
65
|
-
}),
|
|
89
|
+
close,
|
|
66
90
|
};
|
|
67
91
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readSession } from "./auth/session.mjs";
|
|
3
|
+
import { GENERIC_ERROR_MESSAGE, UpstreamError } from "./auth/backend.mjs";
|
|
3
4
|
import { BackendResponseError } from "./backend-response.mjs";
|
|
4
5
|
import { heldOnceProps, requestedProps } from "./inertia.mjs";
|
|
6
|
+
import { isObjectLike, isRecord } from "./type-guards.mjs";
|
|
5
7
|
|
|
6
8
|
export const SHARED_PROP = "shared";
|
|
7
9
|
/**
|
|
@@ -15,10 +17,59 @@ export const SHARED_PROP_TTL_MS = 5 * 60 * 1000;
|
|
|
15
17
|
const SITE_LAYOUT_PATH = "/dms/sitelayout";
|
|
16
18
|
const NOT_FOUND = 404;
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The props of a page, each only when the request asks for it.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {{
|
|
24
|
+
* path?: string,
|
|
25
|
+
* user?: unknown,
|
|
26
|
+
* session?: unknown,
|
|
27
|
+
* page?: unknown,
|
|
28
|
+
* shared?: unknown
|
|
29
|
+
* }} PageProps
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a page request reads of the backend: its JSON for a path (any value;
|
|
34
|
+
* `loadPage` refuses a page answer that is not an object), and whether the
|
|
35
|
+
* frontend itself serves a path.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {{
|
|
38
|
+
* fetchJson: (path: string) => Promise<unknown>,
|
|
39
|
+
* isFrontendPage: (pathname: string) => Promise<boolean>
|
|
40
|
+
* }} PageBackend
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a page request reads of the auth hooks: where sign-in is, and what a
|
|
45
|
+
* session shows a page.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {{
|
|
48
|
+
* signInPath: string,
|
|
49
|
+
* view: (session: import("./auth/session.mjs").Session | undefined) => { user?: unknown, session?: unknown }
|
|
50
|
+
* }} PageAuth
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The route the backend described for the page of `props`, as the backend
|
|
55
|
+
* sent it: any JSON value, `undefined` when the page has none.
|
|
56
|
+
*
|
|
57
|
+
* @param {PageProps} props
|
|
58
|
+
* @returns {unknown}
|
|
59
|
+
*/
|
|
60
|
+
export function pageRoute(props) {
|
|
61
|
+
return isObjectLike(props.page) ? props.page.route : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
/**
|
|
19
65
|
* The once-prop key of the site layout: one per account and workspace. A
|
|
20
66
|
* browser holding the layout of another account or workspace, after a sign-in,
|
|
21
67
|
* a sign-out or a switch, holds another key, so it is sent the right one.
|
|
68
|
+
*
|
|
69
|
+
* @param {{ accountId?: string, activeTenantId?: string } | undefined} session
|
|
70
|
+
* The sealed session, of which only the `accountId` and `activeTenantId` are
|
|
71
|
+
* read, as empty when it holds none
|
|
72
|
+
* @returns {string}
|
|
22
73
|
*/
|
|
23
74
|
export function sharedOnceKey(session) {
|
|
24
75
|
const identity = `${session?.accountId ?? ""}\n${session?.activeTenantId ?? ""}`;
|
|
@@ -30,6 +81,10 @@ export function sharedOnceKey(session) {
|
|
|
30
81
|
* Whether the browser holds this session's layout and no other: a browser
|
|
31
82
|
* that holds another session's key too (a switch back and forth without a
|
|
32
83
|
* full visit) may show that one under `shared`, so it is sent this one.
|
|
84
|
+
*
|
|
85
|
+
* @param {Set<string>} held
|
|
86
|
+
* @param {string} sharedKey
|
|
87
|
+
* @returns {boolean}
|
|
33
88
|
*/
|
|
34
89
|
function holdsOnlyThisLayout(held, sharedKey) {
|
|
35
90
|
if (!held.has(sharedKey)) return false;
|
|
@@ -50,19 +105,29 @@ const PAGE_REQUEST_PARAMETERS = new Set(["path", SHARED_PROP]);
|
|
|
50
105
|
* on), so the layout is resolved for the query the browser shows and the
|
|
51
106
|
* browser does not ask it again. One value per name, the last, as the
|
|
52
107
|
* browser's own page layout request sends it.
|
|
108
|
+
*
|
|
109
|
+
* @param {URL} url
|
|
110
|
+
* @param {boolean} withShared
|
|
111
|
+
* @returns {string}
|
|
53
112
|
*/
|
|
54
113
|
function pagePath(url, withShared) {
|
|
55
114
|
const query = new URLSearchParams({ path: url.pathname });
|
|
115
|
+
/** @type {Map<string, string>} */
|
|
56
116
|
const pageQuery = new Map();
|
|
57
117
|
for (const [name, value] of url.searchParams) {
|
|
58
118
|
if (!PAGE_REQUEST_PARAMETERS.has(name)) pageQuery.set(name, value);
|
|
59
119
|
}
|
|
60
120
|
for (const [name, value] of pageQuery) query.set(name, value);
|
|
61
121
|
if (!withShared) query.set(SHARED_PROP, "false");
|
|
62
|
-
return `/dms/page?${query}`;
|
|
122
|
+
return `/dms/page?${query.toString()}`;
|
|
63
123
|
}
|
|
64
124
|
|
|
65
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* A page only the frontend has: a custom page, with no backend data.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} pathname
|
|
129
|
+
* @param {string} signInPath
|
|
130
|
+
*/
|
|
66
131
|
function frontendOnlyPage(pathname, signInPath) {
|
|
67
132
|
return {
|
|
68
133
|
componentName: pathname,
|
|
@@ -76,6 +141,13 @@ function frontendOnlyPage(pathname, signInPath) {
|
|
|
76
141
|
};
|
|
77
142
|
}
|
|
78
143
|
|
|
144
|
+
/**
|
|
145
|
+
* @param {URL} url
|
|
146
|
+
* @param {boolean} withShared
|
|
147
|
+
* @param {PageBackend} backend
|
|
148
|
+
* @param {string} signInPath
|
|
149
|
+
* @returns {Promise<{ page: PageProps["page"], shared: unknown }>}
|
|
150
|
+
*/
|
|
79
151
|
async function loadPage(
|
|
80
152
|
url,
|
|
81
153
|
withShared,
|
|
@@ -84,7 +156,10 @@ async function loadPage(
|
|
|
84
156
|
) {
|
|
85
157
|
const { pathname } = url;
|
|
86
158
|
try {
|
|
87
|
-
const
|
|
159
|
+
const answer = await fetchJson(pagePath(url, withShared));
|
|
160
|
+
if (!isRecord(answer) || Array.isArray(answer))
|
|
161
|
+
throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
|
|
162
|
+
const { shared, ...page } = answer;
|
|
88
163
|
return { page, shared };
|
|
89
164
|
} catch (error) {
|
|
90
165
|
if (!(error instanceof BackendResponseError) || error.status !== NOT_FOUND)
|
|
@@ -101,6 +176,12 @@ async function loadPage(
|
|
|
101
176
|
* The props of the same page at another query of its path (`url`): the page
|
|
102
177
|
* is read again, since its layout reads the query, and the rest is kept,
|
|
103
178
|
* the site layout included.
|
|
179
|
+
*
|
|
180
|
+
* @param {PageProps} props
|
|
181
|
+
* @param {URL} url
|
|
182
|
+
* @param {PageBackend} backend
|
|
183
|
+
* @param {PageAuth} auth
|
|
184
|
+
* @returns {Promise<PageProps>}
|
|
104
185
|
*/
|
|
105
186
|
export async function propsForQuery(props, url, backend, auth) {
|
|
106
187
|
const { page } = await loadPage(url, false, backend, auth.signInPath);
|
|
@@ -125,6 +206,14 @@ export async function propsForQuery(props, url, backend, auth) {
|
|
|
125
206
|
* The shared layout is declared only when the browser ends up with this
|
|
126
207
|
* session's layout, sent now or already held: declaring it otherwise would let
|
|
127
208
|
* a layout of another session pass for this one.
|
|
209
|
+
*
|
|
210
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
211
|
+
* @param {PageBackend} backend
|
|
212
|
+
* @param {PageAuth} auth
|
|
213
|
+
* @returns {Promise<{
|
|
214
|
+
* props: PageProps,
|
|
215
|
+
* onceProps: import("./inertia.mjs").OnceProps | undefined
|
|
216
|
+
* }>}
|
|
128
217
|
*/
|
|
129
218
|
export async function loadPageProps(request, backend, auth) {
|
|
130
219
|
const url = new URL(request.url, "http://frontend.local");
|
|
@@ -135,10 +224,12 @@ export async function loadPageProps(request, backend, auth) {
|
|
|
135
224
|
const sharedKey = sharedOnceKey(sealed);
|
|
136
225
|
const holdsShared = holdsOnlyThisLayout(heldOnceProps(request), sharedKey);
|
|
137
226
|
const withShared = (partial && wants(SHARED_PROP)) || !holdsShared;
|
|
227
|
+
/** @type {PageProps} */
|
|
138
228
|
const props = {};
|
|
139
229
|
if (wants("path")) props.path = pathname;
|
|
140
230
|
if (wants("user") && (user || partial)) props.user = user ?? null;
|
|
141
231
|
if (wants("session") && (session || partial)) props.session = session ?? null;
|
|
232
|
+
/** @type {unknown} */
|
|
142
233
|
let shared;
|
|
143
234
|
if (wants("page")) {
|
|
144
235
|
const loaded = await loadPage(url, withShared, backend, auth.signInPath);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readSession } from "./auth/session.mjs";
|
|
2
|
+
import { HOMEPAGE } from "./homepage.mjs";
|
|
3
|
+
import { redirectFrontendVisit } from "./inertia.mjs";
|
|
4
|
+
import { pageRoute } from "./page-props.mjs";
|
|
5
|
+
import { isObjectLike } from "./type-guards.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {import("node:http").IncomingMessage} request
|
|
9
|
+
* @param {import("node:http").ServerResponse} response
|
|
10
|
+
* @param {string} pathname
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function redirectToHomepage(request, response, pathname) {
|
|
14
|
+
if (pathname !== "/" || HOMEPAGE === "/") return false;
|
|
15
|
+
redirectFrontendVisit(request, response, HOMEPAGE);
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
21
|
+
* @param {import("node:http").ServerResponse} response
|
|
22
|
+
* @param {import("./page-props.mjs").PageProps} props
|
|
23
|
+
* @param {import("./module-server.mjs").ServerModules["serverAuth"]} auth
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function redirectSignedOutPrivatePage(request, response, props, auth) {
|
|
27
|
+
const route = pageRoute(props);
|
|
28
|
+
if (
|
|
29
|
+
!isObjectLike(route) ||
|
|
30
|
+
route.publicAccess === true ||
|
|
31
|
+
route.hasAccess !== false ||
|
|
32
|
+
readSession(request)
|
|
33
|
+
)
|
|
34
|
+
return false;
|
|
35
|
+
redirectFrontendVisit(request, response, auth.signInLocation(request.url));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
+
import { isString } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
2
4
|
|
|
5
|
+
/** The forms a path pattern takes, as the warnings name them. */
|
|
6
|
+
export const PATH_PATTERN_FORMS = "/x, /x/* or /x/**";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The wildcard a pattern may end with: what it matches below the literal
|
|
10
|
+
* segments, and how it ranks against a pattern with the same literals.
|
|
11
|
+
*/
|
|
12
|
+
const TAILS = new Map([
|
|
13
|
+
["*", { matcher: "/[^/]+", rank: 3 }],
|
|
14
|
+
["**", { matcher: "(?:/.*)?", rank: 0 }],
|
|
15
|
+
]);
|
|
16
|
+
const NO_TAIL = { matcher: "", rank: 1 };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} CompiledPathPattern
|
|
20
|
+
* @property {string} pattern The pattern as declared
|
|
21
|
+
* @property {number} specificity Orders patterns from the least to the most specific
|
|
22
|
+
* @property {(pathname: string) => boolean} test Whether the pattern covers `pathname`
|
|
23
|
+
*/
|
|
24
|
+
|
|
3
25
|
/**
|
|
4
26
|
* A pattern made usable, or `undefined` when it is malformed.
|
|
5
27
|
*
|
|
@@ -8,31 +30,32 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
|
|
|
8
30
|
*
|
|
9
31
|
* Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
|
|
10
32
|
* page itself, `/x/*` one segment below it, `/x/**` the page and everything
|
|
11
|
-
* below it. Nothing else:
|
|
33
|
+
* below it. Nothing else: a wildcard only as the last segment, no prefix
|
|
34
|
+
* match inside a segment, no query string.
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} pattern
|
|
37
|
+
* @returns {CompiledPathPattern | undefined}
|
|
12
38
|
*/
|
|
13
39
|
export function compilePathPattern(pattern) {
|
|
14
|
-
if (
|
|
40
|
+
if (!isString(pattern) || !pattern.startsWith("/")) return undefined;
|
|
15
41
|
const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
16
|
-
const
|
|
17
|
-
const literal =
|
|
42
|
+
const last = TAILS.get(segments.at(-1) ?? "");
|
|
43
|
+
const literal = last ? segments.slice(0, -1) : segments;
|
|
18
44
|
if (
|
|
19
45
|
literal.some(
|
|
20
46
|
(segment) =>
|
|
21
|
-
segment
|
|
22
|
-
(!SEGMENT.test(segment) || segment === "." || segment === ".."),
|
|
47
|
+
!SEGMENT.test(segment) || segment === "." || segment === "..",
|
|
23
48
|
)
|
|
24
49
|
)
|
|
25
50
|
return undefined;
|
|
51
|
+
const tail = last ?? NO_TAIL;
|
|
26
52
|
const body = literal
|
|
27
|
-
.map((segment) =>
|
|
28
|
-
segment === "*" ? "/[^/]+" : `/${segment.replaceAll(".", "\\.")}`,
|
|
29
|
-
)
|
|
53
|
+
.map((segment) => `/${segment.replaceAll(".", "\\.")}`)
|
|
30
54
|
.join("");
|
|
31
|
-
const matcher = new RegExp(`^${body}${
|
|
32
|
-
const literals = literal.filter((segment) => segment !== "*").length;
|
|
55
|
+
const matcher = new RegExp(`^${body}${tail.matcher}/?$`);
|
|
33
56
|
return {
|
|
34
57
|
pattern,
|
|
35
|
-
specificity:
|
|
58
|
+
specificity: literal.length * 6 + tail.rank,
|
|
36
59
|
test: (pathname) => matcher.test(pathname === "" ? "/" : pathname),
|
|
37
60
|
};
|
|
38
61
|
}
|