@antelopejs/dms-frontend 0.10.0 → 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 +34 -11
- package/dist/commands/build-action.d.ts +3 -0
- package/dist/commands/build-action.js +74 -6
- 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 +2 -2
- package/dist/commands/dev.js +1 -1
- package/dist/config.js +1 -0
- package/dist/derived-outputs.d.ts +1 -0
- package/dist/derived-outputs.js +3 -1
- package/dist/help.js +1 -1
- package/dist/internal/prepared-workspace.d.ts +12 -0
- package/dist/internal/prepared-workspace.js +74 -0
- package/dist/layer-watch.js +1 -1
- package/dist/locale-files.d.ts +4 -2
- package/dist/locale-files.js +11 -9
- package/dist/manifest.js +2 -1
- package/dist/ports.d.ts +3 -1
- package/dist/ports.js +1 -1
- package/dist/project-modules.d.ts +5 -1
- package/dist/project-modules.js +10 -7
- package/dist/verify-source-runner.js +2 -44
- 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 +3 -2
- package/package.json +7 -5
- package/templates/vue/backend-boundary.mjs +132 -0
- package/templates/vue/client-chunking.mjs +2 -0
- package/templates/vue/entry-weight.mjs +9 -5
- package/templates/vue/frontend-build.ts +4 -3
- package/templates/vue/head-order.mjs +3 -2
- package/templates/vue/icon-api.mjs +2 -0
- package/templates/vue/server/auth/backend.mjs +4 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +2 -2
- package/templates/vue/server/auth/session.mjs +5 -2
- package/templates/vue/server/auth-declarations.mjs +46 -0
- package/templates/vue/server/auth-hooks.mjs +41 -3
- package/templates/vue/server/backend-proxies.mjs +16 -12
- package/templates/vue/server/backend-response.mjs +3 -3
- package/templates/vue/server/cli-report.mjs +22 -1
- package/templates/vue/server/client-error.mjs +5 -0
- package/templates/vue/server/content.mjs +24 -11
- package/templates/vue/server/email-bundle-watch.mjs +9 -3
- package/templates/vue/server/email.mjs +5 -3
- package/templates/vue/server/generated-files.mjs +16 -6
- package/templates/vue/server/icons.mjs +15 -11
- package/templates/vue/server/in-place-render.mjs +3 -3
- package/templates/vue/server/inertia.mjs +17 -3
- package/templates/vue/server/module-event.mjs +28 -16
- package/templates/vue/server/module-route-types.mjs +2 -2
- package/templates/vue/server/module-routes.mjs +24 -76
- package/templates/vue/server/module-server.mjs +27 -17
- package/templates/vue/server/module-testing.mjs +23 -8
- package/templates/vue/server/page-props.mjs +6 -5
- package/templates/vue/server/page-redirects.mjs +1 -1
- package/templates/vue/server/path-patterns.mjs +22 -11
- package/templates/vue/server/proxy-headers.mjs +1 -1
- package/templates/vue/server/proxy-redirects.mjs +1 -0
- package/templates/vue/server/proxy-request.mjs +2 -1
- package/templates/vue/server/proxy-transfers.mjs +17 -11
- package/templates/vue/server/public-endpoints.mjs +12 -16
- package/templates/vue/server/request-failure.mjs +1 -1
- package/templates/vue/server/request-routing.mjs +6 -1
- package/templates/vue/server/request-target.mjs +22 -9
- package/templates/vue/server/route-rules.mjs +12 -6
- package/templates/vue/server/static-files.mjs +12 -7
- package/templates/vue/server/vite-errors.mjs +13 -2
- package/templates/vue/server/web-body.mjs +2 -1
- package/templates/vue/server-module.ts +2 -1
- package/templates/vue/server.mjs +41 -20
- package/templates/vue/ui-inertia-page.mjs +1 -1
- package/templates/vue/vite.config.ts +9 -0
|
@@ -13,7 +13,11 @@ const WILDCARD_ADDRESSES = ["0.0.0.0", "::"];
|
|
|
13
13
|
*/
|
|
14
14
|
async function report(message, line, write) {
|
|
15
15
|
if (!process.send) return write(line);
|
|
16
|
-
|
|
16
|
+
/** @type {Promise<void>} */
|
|
17
|
+
const sent = new Promise((resolve) =>
|
|
18
|
+
process.send?.(message, () => resolve()),
|
|
19
|
+
);
|
|
20
|
+
await sent;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -40,6 +44,23 @@ export async function reportReady(address, port) {
|
|
|
40
44
|
);
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/** The `ANTELOPEJS_QUIET` values that leave a run verbose, as `@antelopejs/core` reads them. */
|
|
48
|
+
const DISABLED_FLAG_VALUES = ["0", "false"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prints the routing summary, unless the run is quiet: `-q` given to `ajs` or
|
|
52
|
+
* `ajs dms` reaches the server as `ANTELOPEJS_QUIET`, and a quiet ready server
|
|
53
|
+
* prints only its `Local` url.
|
|
54
|
+
*
|
|
55
|
+
* @param {readonly string[]} lines What `routingReport` returned
|
|
56
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
57
|
+
*/
|
|
58
|
+
export function reportRouting(lines, env = process.env) {
|
|
59
|
+
const quiet = env.ANTELOPEJS_QUIET;
|
|
60
|
+
if (quiet && !DISABLED_FLAG_VALUES.includes(quiet.toLowerCase())) return;
|
|
61
|
+
for (const line of lines) console.log(line);
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* The server cannot listen: a port taken by another process, an address this
|
|
45
66
|
* host does not have. Reported instead of Node's unhandled-error dump, then
|
|
@@ -11,6 +11,7 @@ const HEADER_OVERFLOW_HINT_INTERVAL_MS = 60_000;
|
|
|
11
11
|
|
|
12
12
|
let lastHeaderOverflowHint = -Infinity;
|
|
13
13
|
|
|
14
|
+
/** @param {number} maxHeaderSize */
|
|
14
15
|
function warnHeaderOverflow(maxHeaderSize) {
|
|
15
16
|
const now = Date.now();
|
|
16
17
|
if (now - lastHeaderOverflowHint < HEADER_OVERFLOW_HINT_INTERVAL_MS) return;
|
|
@@ -32,6 +33,10 @@ function warnHeaderOverflow(maxHeaderSize) {
|
|
|
32
33
|
*
|
|
33
34
|
* Once a response went out on this connection, a status line would land in the
|
|
34
35
|
* middle of it.
|
|
36
|
+
*
|
|
37
|
+
* @param {NodeJS.ErrnoException} error
|
|
38
|
+
* @param {import("node:stream").Duplex & { bytesWritten?: number }} socket
|
|
39
|
+
* @param {number} maxHeaderSize
|
|
35
40
|
*/
|
|
36
41
|
export function handleClientError(error, socket, maxHeaderSize) {
|
|
37
42
|
let response = BAD_REQUEST_RESPONSE;
|
|
@@ -11,17 +11,14 @@ export const MINIMUM_COMPRESSION_BYTES = 1_024;
|
|
|
11
11
|
const DYNAMIC_BROTLI_QUALITY = 4;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* client gets each part when it is written, not when a block fills.
|
|
18
|
-
*
|
|
19
|
-
* Implicit identity is a fallback, not a preference over compression.
|
|
14
|
+
* The codings among `br` and `gzip` the client accepts, most wanted first
|
|
15
|
+
* (Brotli on a tie). Empty when it accepts neither, or ranks identity above
|
|
16
|
+
* both: implicit identity is a fallback, not a preference over compression.
|
|
20
17
|
*
|
|
21
18
|
* @param {import("node:http").IncomingMessage} request
|
|
22
|
-
* @
|
|
19
|
+
* @returns {("br" | "gzip")[]}
|
|
23
20
|
*/
|
|
24
|
-
export function
|
|
21
|
+
export function acceptedCodings(request) {
|
|
25
22
|
const accepted = request.headers["accept-encoding"] ?? "";
|
|
26
23
|
/** @type {Map<string, number>} */
|
|
27
24
|
const qualities = new Map();
|
|
@@ -46,8 +43,24 @@ export function acceptedEncoder(request, { streaming = false } = {}) {
|
|
|
46
43
|
const br = quality("br");
|
|
47
44
|
const gzip = quality("gzip");
|
|
48
45
|
const identity = qualities.get("identity") ?? 0;
|
|
49
|
-
if (identity > Math.max(br, gzip)) return
|
|
50
|
-
|
|
46
|
+
if (identity > Math.max(br, gzip)) return [];
|
|
47
|
+
/** @type {("br" | "gzip")[]} */
|
|
48
|
+
const ranked = gzip > br ? ["gzip", "br"] : ["br", "gzip"];
|
|
49
|
+
return ranked.filter((coding) => quality(coding) > 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A compressor for a body generated or relayed now, in the encoding the client
|
|
54
|
+
* wants most (see `acceptedCodings`), or `undefined` when it accepts neither.
|
|
55
|
+
* `streaming` flushes what each write gives at once, for a body written over
|
|
56
|
+
* time: the client gets each part when it is written, not when a block fills.
|
|
57
|
+
*
|
|
58
|
+
* @param {import("node:http").IncomingMessage} request
|
|
59
|
+
* @param {{ streaming?: boolean }} [options]
|
|
60
|
+
*/
|
|
61
|
+
export function acceptedEncoder(request, { streaming = false } = {}) {
|
|
62
|
+
const [coding] = acceptedCodings(request);
|
|
63
|
+
if (coding === "br")
|
|
51
64
|
return {
|
|
52
65
|
encoding: "br",
|
|
53
66
|
stream: createBrotliCompress({
|
|
@@ -57,7 +70,7 @@ export function acceptedEncoder(request, { streaming = false } = {}) {
|
|
|
57
70
|
},
|
|
58
71
|
}),
|
|
59
72
|
};
|
|
60
|
-
if (
|
|
73
|
+
if (coding === "gzip")
|
|
61
74
|
return {
|
|
62
75
|
encoding: "gzip",
|
|
63
76
|
stream: createGzip(
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { build } from "vite";
|
|
3
|
+
import { isObjectLike } from "./type-guards.mjs";
|
|
3
4
|
|
|
4
5
|
const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
5
6
|
|
|
6
7
|
process.on("disconnect", () => process.exit(0));
|
|
7
8
|
|
|
9
|
+
/** @type {(() => void) | undefined} */
|
|
8
10
|
let startBuild;
|
|
9
11
|
process.on("message", (message) => {
|
|
10
|
-
if (message
|
|
12
|
+
if (!isObjectLike(message) || message.state !== "build") return;
|
|
11
13
|
startBuild?.();
|
|
12
14
|
startBuild = undefined;
|
|
13
15
|
});
|
|
@@ -17,10 +19,12 @@ const serverHandoff = {
|
|
|
17
19
|
name: "dms-email-bundle-handoff",
|
|
18
20
|
buildStart() {
|
|
19
21
|
if (!process.send) return;
|
|
20
|
-
|
|
22
|
+
/** @type {Promise<void>} */
|
|
23
|
+
const held = new Promise((start) => {
|
|
21
24
|
startBuild = start;
|
|
22
|
-
process.send({ state: "building" });
|
|
25
|
+
process.send?.({ state: "building" });
|
|
23
26
|
});
|
|
27
|
+
return held;
|
|
24
28
|
},
|
|
25
29
|
};
|
|
26
30
|
|
|
@@ -34,6 +38,8 @@ const watcher = await build({
|
|
|
34
38
|
build: { watch: {} },
|
|
35
39
|
});
|
|
36
40
|
|
|
41
|
+
if (!("on" in watcher))
|
|
42
|
+
throw new TypeError("The email bundle build did not start a watcher");
|
|
37
43
|
watcher.on("event", (event) => {
|
|
38
44
|
if (event.code === "END") process.send?.({ state: "ready" });
|
|
39
45
|
else if (event.code === "ERROR") process.send?.({ state: "failed" });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fork } from "node:child_process";
|
|
2
2
|
import { constants, setPriority } from "node:os";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { isObjectLike } from "./type-guards.mjs";
|
|
4
5
|
|
|
5
6
|
const EMAIL_RENDERER_URL = new URL(
|
|
6
7
|
"../dist/server/email-renderer.js",
|
|
@@ -75,10 +76,11 @@ export function watchEmailBundle() {
|
|
|
75
76
|
cwd: fileURLToPath(new URL("..", import.meta.url)),
|
|
76
77
|
});
|
|
77
78
|
try {
|
|
78
|
-
|
|
79
|
+
if (builder.pid !== undefined)
|
|
80
|
+
setPriority(builder.pid, constants.priority.PRIORITY_BELOW_NORMAL);
|
|
79
81
|
} catch {}
|
|
80
82
|
builder.on("message", (message) => {
|
|
81
|
-
if (message
|
|
83
|
+
if (!isObjectLike(message) || message.state !== "building") {
|
|
82
84
|
emailBuildSettled();
|
|
83
85
|
return;
|
|
84
86
|
}
|
|
@@ -115,7 +117,7 @@ async function waitForEmailBuild() {
|
|
|
115
117
|
/** @returns {Promise<typeof import("../email-renderer.ts")>} */
|
|
116
118
|
function loadEmailRenderer() {
|
|
117
119
|
const url = new URL(EMAIL_RENDERER_URL);
|
|
118
|
-
if (emailBundleVersion) url.searchParams.set("v", emailBundleVersion);
|
|
120
|
+
if (emailBundleVersion) url.searchParams.set("v", String(emailBundleVersion));
|
|
119
121
|
return import(url.href);
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -115,6 +115,14 @@ function isManifestChunk(value) {
|
|
|
115
115
|
);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* @param {unknown} value
|
|
120
|
+
* @returns {value is import("vite").Manifest}
|
|
121
|
+
*/
|
|
122
|
+
function isManifest(value) {
|
|
123
|
+
return isPlainRecord(value) && Object.values(value).every(isManifestChunk);
|
|
124
|
+
}
|
|
125
|
+
|
|
118
126
|
/**
|
|
119
127
|
* The manifest `vite build` wrote to `dist/client/.vite/manifest.json`.
|
|
120
128
|
*
|
|
@@ -123,7 +131,7 @@ function isManifestChunk(value) {
|
|
|
123
131
|
* @throws {Error} When it is not an object of chunks
|
|
124
132
|
*/
|
|
125
133
|
export function viteManifestOf(content) {
|
|
126
|
-
if (!
|
|
134
|
+
if (!isManifest(content))
|
|
127
135
|
throw new Error("the client manifest is not an object of Vite chunks");
|
|
128
136
|
return content;
|
|
129
137
|
}
|
|
@@ -132,8 +140,12 @@ export function viteManifestOf(content) {
|
|
|
132
140
|
* The members of an Iconify collection that `iconCollectionOf` checks, the
|
|
133
141
|
* ones an icon query and a server render read.
|
|
134
142
|
*
|
|
143
|
+
* @typedef {Parameters<typeof import("@iconify/utils").getIcons>[0]} Iconify
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
135
147
|
* @typedef {Pick<
|
|
136
|
-
*
|
|
148
|
+
* Iconify,
|
|
137
149
|
* | "prefix"
|
|
138
150
|
* | "icons"
|
|
139
151
|
* | "aliases"
|
|
@@ -142,10 +154,8 @@ export function viteManifestOf(content) {
|
|
|
142
154
|
* | "top"
|
|
143
155
|
* | "width"
|
|
144
156
|
* | "height"
|
|
145
|
-
*
|
|
146
|
-
* | "hFlip"
|
|
147
|
-
* | "vFlip"
|
|
148
|
-
* >} IconCollection
|
|
157
|
+
* > &
|
|
158
|
+
* Pick<Iconify["icons"][string], "rotate" | "hFlip" | "vFlip">} IconCollection
|
|
149
159
|
*/
|
|
150
160
|
|
|
151
161
|
/**
|
|
@@ -104,12 +104,12 @@ async function loadCollection(root, prefix) {
|
|
|
104
104
|
*/
|
|
105
105
|
function collection(root, prefix) {
|
|
106
106
|
const key = join(root, prefix);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
107
|
+
const cached = collections.get(key);
|
|
108
|
+
if (cached) return cached;
|
|
109
|
+
const loading = loadCollection(root, prefix);
|
|
110
|
+
loading.catch(() => collections.delete(key));
|
|
111
|
+
collections.set(key, loading);
|
|
112
|
+
return loading;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/**
|
|
@@ -128,7 +128,7 @@ export async function installedCollections(root) {
|
|
|
128
128
|
.filter((prefix) => ICON_NAME.test(prefix))
|
|
129
129
|
.map((prefix) => collection(root, prefix)),
|
|
130
130
|
);
|
|
131
|
-
return loaded.filter(
|
|
131
|
+
return loaded.filter((loadedCollection) => loadedCollection !== undefined);
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
@@ -193,13 +193,17 @@ function requestedIcons(url) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
* Whether the generated server answers `pathname` with `iconResponse
|
|
196
|
+
* Whether the generated server answers `pathname` with `iconResponse`: the
|
|
197
|
+
* icon endpoint, with or without its trailing slash, and every path under it.
|
|
197
198
|
*
|
|
198
199
|
* @param {string} pathname
|
|
199
200
|
* @returns {boolean}
|
|
200
201
|
*/
|
|
201
202
|
export function isIconRequest(pathname) {
|
|
202
|
-
return
|
|
203
|
+
return (
|
|
204
|
+
pathname === ICON_API_PATH.slice(0, -1) ||
|
|
205
|
+
pathname.startsWith(ICON_API_PATH)
|
|
206
|
+
);
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
/**
|
|
@@ -214,7 +218,7 @@ export function isIconRequest(pathname) {
|
|
|
214
218
|
* @returns {Promise<IconAnswer>}
|
|
215
219
|
*/
|
|
216
220
|
export async function iconResponse(root, method, url) {
|
|
217
|
-
if (!READ_METHODS.has(method)) {
|
|
221
|
+
if (method === undefined || !READ_METHODS.has(method)) {
|
|
218
222
|
const response = errorResponse(405, "Method not allowed");
|
|
219
223
|
response.headers.allow = [...READ_METHODS].join(", ");
|
|
220
224
|
return response;
|
|
@@ -234,7 +238,7 @@ export async function iconResponse(root, method, url) {
|
|
|
234
238
|
* Serve an Iconify API query from the collections installed in the workspace,
|
|
235
239
|
* the same way under `ajs dms dev` and `ajs dms start`: both run this server.
|
|
236
240
|
*
|
|
237
|
-
* @param {import("
|
|
241
|
+
* @param {import("./request-target.mjs").ServerRequest} request
|
|
238
242
|
* @param {import("node:http").ServerResponse} response
|
|
239
243
|
*/
|
|
240
244
|
export async function handleIcons(request, response) {
|
|
@@ -44,7 +44,7 @@ function reusableReadKey(path, options, headers) {
|
|
|
44
44
|
* A read returns a copy: the render that read it may still change the object it
|
|
45
45
|
* got. A body that cannot be copied is read again.
|
|
46
46
|
*
|
|
47
|
-
* @param {() =>
|
|
47
|
+
* @param {() => ConstructorParameters<typeof Headers>[0]} backendHeaders
|
|
48
48
|
* @returns {ServerReads}
|
|
49
49
|
*/
|
|
50
50
|
export function serverReads(backendHeaders) {
|
|
@@ -90,7 +90,7 @@ export function serverReads(backendHeaders) {
|
|
|
90
90
|
* same path. Any other redirect keeps its own response.
|
|
91
91
|
*
|
|
92
92
|
* @param {import("node:http").IncomingMessage} request
|
|
93
|
-
* @param {string
|
|
93
|
+
* @param {string} from
|
|
94
94
|
* @param {string} location
|
|
95
95
|
* @returns {boolean}
|
|
96
96
|
*/
|
|
@@ -114,7 +114,7 @@ function rendersInPlace(request, from, location) {
|
|
|
114
114
|
* A page that completes its query as it renders is rendered at that query in
|
|
115
115
|
* the same request, rather than answered with a redirect.
|
|
116
116
|
*
|
|
117
|
-
* @template {{ url: string
|
|
117
|
+
* @template {{ url: string, props: unknown }} Page
|
|
118
118
|
* @template {{ redirect?: string }} Rendered
|
|
119
119
|
* @param {import("node:http").IncomingMessage} request
|
|
120
120
|
* @param {Page} page
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { isObjectLike, isString } from "./type-guards.mjs";
|
|
4
5
|
|
|
5
6
|
const CLIENT_MANIFEST_PATH = fileURLToPath(
|
|
6
7
|
new URL("../dist/client/.vite/manifest.json", import.meta.url),
|
|
@@ -53,14 +54,26 @@ export const DMS_PAGE_COMPONENT = "DmsDynamicPage";
|
|
|
53
54
|
* @typedef {{
|
|
54
55
|
* component: string,
|
|
55
56
|
* props: Record<string, unknown>,
|
|
56
|
-
* url: string
|
|
57
|
+
* url: string,
|
|
57
58
|
* version: string,
|
|
58
59
|
* onceProps?: OnceProps
|
|
59
60
|
* }} InertiaPage
|
|
60
61
|
*/
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
+
* Whether the page has the props the server render needs: the path and the
|
|
65
|
+
* page the backend described. A full visit carries both; the page payload is
|
|
66
|
+
* the backend's JSON, read as the renderer reads it.
|
|
67
|
+
*
|
|
68
|
+
* @param {InertiaPage} page
|
|
69
|
+
* @returns {page is InertiaPage & import("../ssr-renderer.ts").DmsSsrPage}
|
|
70
|
+
*/
|
|
71
|
+
export function isSsrPage(page) {
|
|
72
|
+
return isString(page.props.path) && isObjectLike(page.props.page);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} url
|
|
64
77
|
* @param {Record<string, unknown> | undefined} props
|
|
65
78
|
* @param {string} [version]
|
|
66
79
|
* @param {OnceProps} [onceProps]
|
|
@@ -72,6 +85,7 @@ export function createInertiaPage(
|
|
|
72
85
|
version = assetVersion(),
|
|
73
86
|
onceProps,
|
|
74
87
|
) {
|
|
88
|
+
/** @type {InertiaPage} */
|
|
75
89
|
const page = {
|
|
76
90
|
component: DMS_PAGE_COMPONENT,
|
|
77
91
|
props: { ...props, errors: props?.errors ?? {} },
|
|
@@ -180,7 +194,7 @@ export function isFrontendVisit(request) {
|
|
|
180
194
|
if (request.method !== "GET") return false;
|
|
181
195
|
return (
|
|
182
196
|
Boolean(request.headers["x-inertia"]) ||
|
|
183
|
-
request.headers.accept?.includes("text/html")
|
|
197
|
+
request.headers.accept?.includes("text/html") === true
|
|
184
198
|
);
|
|
185
199
|
}
|
|
186
200
|
|
|
@@ -25,13 +25,15 @@ import {
|
|
|
25
25
|
import { backendUrl } from "./request-target.mjs";
|
|
26
26
|
import {
|
|
27
27
|
isFunction,
|
|
28
|
-
isNonNullObject,
|
|
29
28
|
isNumber,
|
|
29
|
+
isObjectLike,
|
|
30
|
+
isRecord,
|
|
30
31
|
isString,
|
|
31
32
|
} from "./type-guards.mjs";
|
|
32
33
|
import { pipeWebBody } from "./web-body.mjs";
|
|
33
34
|
|
|
34
35
|
/** @typedef {import("node:http").IncomingMessage} IncomingMessage */
|
|
36
|
+
/** @typedef {import("./request-target.mjs").ServerRequest} ServerRequest */
|
|
35
37
|
/** @typedef {import("node:http").ServerResponse} ServerResponse */
|
|
36
38
|
/** @typedef {import("../server-module.ts").DmsServerEvent} ServerEvent */
|
|
37
39
|
/** @typedef {import("../server-module.ts").DmsSession} Session */
|
|
@@ -40,7 +42,7 @@ import { pipeWebBody } from "./web-body.mjs";
|
|
|
40
42
|
* What an event is made from.
|
|
41
43
|
*
|
|
42
44
|
* @typedef {object} EventContext
|
|
43
|
-
* @property {
|
|
45
|
+
* @property {ServerRequest} request
|
|
44
46
|
* @property {ServerResponse} response
|
|
45
47
|
* @property {Pick<import("./module-route-types.mjs").ServerRoute, "bodyLimit">} route
|
|
46
48
|
* @property {Readonly<Record<string, string>>} params
|
|
@@ -97,7 +99,7 @@ function limitedBody(request, limit) {
|
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/**
|
|
100
|
-
* @param {
|
|
102
|
+
* @param {ServerRequest} request
|
|
101
103
|
* @param {EventContext["route"]} route
|
|
102
104
|
*/
|
|
103
105
|
function webRequest(request, route) {
|
|
@@ -124,9 +126,13 @@ async function readJson(webRequestValue) {
|
|
|
124
126
|
const chunks = [];
|
|
125
127
|
const reader = webRequestValue.body.getReader();
|
|
126
128
|
for (;;) {
|
|
127
|
-
const
|
|
128
|
-
if (done) break;
|
|
129
|
-
|
|
129
|
+
const read = await reader.read();
|
|
130
|
+
if (read.done) break;
|
|
131
|
+
/** @type {unknown} */
|
|
132
|
+
const chunk = read.value;
|
|
133
|
+
if (!(chunk instanceof Uint8Array))
|
|
134
|
+
throw new RequestBodyError(400, "Invalid request body");
|
|
135
|
+
chunks.push(chunk);
|
|
130
136
|
}
|
|
131
137
|
if (!chunks.length) return {};
|
|
132
138
|
try {
|
|
@@ -237,8 +243,16 @@ function frozenCopy(value) {
|
|
|
237
243
|
*/
|
|
238
244
|
export function createServerEvent(context) {
|
|
239
245
|
const { request, response, route, params } = context;
|
|
240
|
-
const
|
|
241
|
-
|
|
246
|
+
const webRequestValue = webRequest(request, route);
|
|
247
|
+
// SAFETY: `server-module.ts` lets a handler name the JSON shape it expects from the backend and from the client, as `Response.json()` does; the server parses that JSON and hands it on unchecked, which these two members are the only place to state.
|
|
248
|
+
const parsedJson = /** @type {Pick<ServerEvent, "backend" | "readJson">} */ ({
|
|
249
|
+
backend: (/** @type {string} */ path, init = {}) =>
|
|
250
|
+
backend(path, request, init),
|
|
251
|
+
readJson: () => readJson(webRequestValue),
|
|
252
|
+
});
|
|
253
|
+
return {
|
|
254
|
+
...parsedJson,
|
|
255
|
+
request: webRequestValue,
|
|
242
256
|
params: Object.freeze({ ...params }),
|
|
243
257
|
session: sessionAccess(request, response, context.view),
|
|
244
258
|
cookies: cookieAccess(
|
|
@@ -247,8 +261,6 @@ export function createServerEvent(context) {
|
|
|
247
261
|
context.module.id,
|
|
248
262
|
context.authOwner,
|
|
249
263
|
),
|
|
250
|
-
backend: (/** @type {string} */ path, init = {}) =>
|
|
251
|
-
backend(path, request, init),
|
|
252
264
|
backendUrl: (/** @type {string} */ path) => backendUrl(path),
|
|
253
265
|
clientIp: () => clientIp(request),
|
|
254
266
|
module: Object.freeze({
|
|
@@ -266,19 +278,18 @@ export function createServerEvent(context) {
|
|
|
266
278
|
...(context.app?.authEstablishEndpoints ?? []),
|
|
267
279
|
]),
|
|
268
280
|
}),
|
|
281
|
+
url: new URL(webRequestValue.url),
|
|
269
282
|
};
|
|
270
|
-
event.url = new URL(event.request.url);
|
|
271
|
-
event.readJson = () => readJson(event.request);
|
|
272
|
-
return event;
|
|
273
283
|
}
|
|
274
284
|
|
|
275
285
|
/** @param {unknown} value */
|
|
276
286
|
function isResponse(value) {
|
|
277
287
|
return (
|
|
278
288
|
value instanceof Response ||
|
|
279
|
-
(
|
|
289
|
+
(isRecord(value) &&
|
|
280
290
|
isNumber(value.status) &&
|
|
281
|
-
|
|
291
|
+
isObjectLike(value.headers) &&
|
|
292
|
+
isFunction(value.headers.get) &&
|
|
282
293
|
"body" in value)
|
|
283
294
|
);
|
|
284
295
|
}
|
|
@@ -304,7 +315,7 @@ function asResponse(result) {
|
|
|
304
315
|
* on the frontend's origin when the frontend proxies its path, no cookie of
|
|
305
316
|
* another site its fetch reached.
|
|
306
317
|
*
|
|
307
|
-
* @param {
|
|
318
|
+
* @param {ServerRequest} request
|
|
308
319
|
* @param {ServerResponse} response
|
|
309
320
|
* @param {unknown} result
|
|
310
321
|
* @param {(path: string) => boolean} isProxiedPath
|
|
@@ -313,6 +324,7 @@ async function writeServerResponse(request, response, result, isProxiedPath) {
|
|
|
313
324
|
const answer = asResponse(result);
|
|
314
325
|
const cookies = [response.getHeader("set-cookie") ?? []]
|
|
315
326
|
.flat()
|
|
327
|
+
.map(String)
|
|
316
328
|
.concat(relayableCookies(answer));
|
|
317
329
|
answer.headers.forEach((value, name) => {
|
|
318
330
|
if (name === "set-cookie") return;
|
|
@@ -2,8 +2,8 @@ export {};
|
|
|
2
2
|
|
|
3
3
|
/** @typedef {import("../server-module.ts").DmsServerMethod} ServerMethod */
|
|
4
4
|
/**
|
|
5
|
-
* What `registerAuth` declared. The
|
|
6
|
-
*
|
|
5
|
+
* What `registerAuth` declared. The server binds each hook to the declared
|
|
6
|
+
* object, so a hook may use `this`.
|
|
7
7
|
*
|
|
8
8
|
* @typedef {object} AuthDeclaration
|
|
9
9
|
* @property {string} [signInPath]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveAuth } from "./auth-declarations.mjs";
|
|
1
2
|
import { resolveBackendProxies } from "./backend-proxies.mjs";
|
|
2
3
|
import { compilePathPattern } from "./path-patterns.mjs";
|
|
3
4
|
import { matchPublicEndpoint } from "./public-endpoints.mjs";
|
|
@@ -12,8 +13,8 @@ import {
|
|
|
12
13
|
/** @typedef {(message: string) => void} Warn */
|
|
13
14
|
/**
|
|
14
15
|
* @import {
|
|
15
|
-
*
|
|
16
|
-
* ServerDeclarations, ServerModuleDeclaration, ServerRoute
|
|
16
|
+
* DeclaredModule, ModuleDeclared, RouteRegistration,
|
|
17
|
+
* ServerDeclarations, ServerMethod, ServerModuleDeclaration, ServerRoute
|
|
17
18
|
* } from "./module-route-types.mjs"
|
|
18
19
|
* @import { PublicEndpoint } from "./public-endpoints.mjs"
|
|
19
20
|
*/
|
|
@@ -156,13 +157,19 @@ function collectDeclarations(setup) {
|
|
|
156
157
|
return declared;
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
/**
|
|
161
|
+
* @param {unknown} name
|
|
162
|
+
* @returns {name is ServerMethod}
|
|
163
|
+
*/
|
|
164
|
+
function isMethod(name) {
|
|
165
|
+
return isString(name) && METHODS.has(name);
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
/** @param {unknown} method */
|
|
160
169
|
function methodsOf(method) {
|
|
161
170
|
/** @type {readonly unknown[]} */
|
|
162
171
|
const methods = Array.isArray(method) ? method : [method];
|
|
163
|
-
return methods.length && methods.every(
|
|
164
|
-
? methods
|
|
165
|
-
: undefined;
|
|
172
|
+
return methods.length && methods.every(isMethod) ? methods : undefined;
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
/** @param {RouteRegistration} registration */
|
|
@@ -176,7 +183,7 @@ function registrationRejection(registration) {
|
|
|
176
183
|
}
|
|
177
184
|
|
|
178
185
|
/**
|
|
179
|
-
* @param {
|
|
186
|
+
* @param {string | readonly string[]} method
|
|
180
187
|
* @param {string} path
|
|
181
188
|
*/
|
|
182
189
|
function describeRoute(method, path) {
|
|
@@ -241,15 +248,16 @@ function declarationsOfModule(declaration, warn) {
|
|
|
241
248
|
function routesOfModule(module, warn) {
|
|
242
249
|
const { id, priority = 0 } = module;
|
|
243
250
|
return module.declared.routes.flatMap((registration, order) => {
|
|
251
|
+
const methods = methodsOf(registration.method);
|
|
244
252
|
const reason = registrationRejection(registration);
|
|
245
|
-
if (reason) {
|
|
253
|
+
if (reason || !methods) {
|
|
246
254
|
warn(
|
|
247
255
|
`Frontend module ${id} declares route ${describeRoute(registration.method, registration.path)}, which is ignored: ${reason}.`,
|
|
248
256
|
);
|
|
249
257
|
return [];
|
|
250
258
|
}
|
|
251
259
|
const compiled = compilePath(registration.path);
|
|
252
|
-
return
|
|
260
|
+
return methods.map((method) => ({
|
|
253
261
|
...compiled,
|
|
254
262
|
id,
|
|
255
263
|
priority,
|
|
@@ -399,70 +407,6 @@ function resolveRoutes(modules, warn, publicEndpoints) {
|
|
|
399
407
|
return routes;
|
|
400
408
|
}
|
|
401
409
|
|
|
402
|
-
const AUTH_HOOKS = ["refreshSession", "publicSession"];
|
|
403
|
-
|
|
404
|
-
/** @param {unknown} auth */
|
|
405
|
-
function authRejection(auth) {
|
|
406
|
-
if (!isNonNullObject(auth)) return "it is not an object";
|
|
407
|
-
for (const hook of AUTH_HOOKS)
|
|
408
|
-
if (auth[hook] !== undefined && !isFunction(auth[hook]))
|
|
409
|
-
return `${hook} is not a function`;
|
|
410
|
-
if (
|
|
411
|
-
auth.signInPath !== undefined &&
|
|
412
|
-
(!isString(auth.signInPath) ||
|
|
413
|
-
!compilePathPattern(auth.signInPath) ||
|
|
414
|
-
auth.signInPath.includes("*"))
|
|
415
|
-
)
|
|
416
|
-
return "signInPath is not a page path";
|
|
417
|
-
if (
|
|
418
|
-
auth.signInExempt !== undefined &&
|
|
419
|
-
!(
|
|
420
|
-
Array.isArray(auth.signInExempt) &&
|
|
421
|
-
auth.signInExempt.every((pattern) => compilePathPattern(pattern))
|
|
422
|
-
)
|
|
423
|
-
)
|
|
424
|
-
return "signInExempt is not a list of /x, /x/* or /x/** patterns";
|
|
425
|
-
return undefined;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* The auth hooks: those of the highest-priority module that registers them.
|
|
430
|
-
* A module registering them twice, or after another one, is reported.
|
|
431
|
-
*
|
|
432
|
-
* @param {DeclaredModule[]} modules
|
|
433
|
-
* @param {Warn} warn
|
|
434
|
-
*/
|
|
435
|
-
function resolveAuth(modules, warn) {
|
|
436
|
-
/** @type {AuthProvider | undefined} */
|
|
437
|
-
let provider;
|
|
438
|
-
for (const module of modules) {
|
|
439
|
-
for (const auth of module.declared.auth) {
|
|
440
|
-
const reason = authRejection(auth);
|
|
441
|
-
if (reason) {
|
|
442
|
-
warn(
|
|
443
|
-
`Frontend module ${module.id} registers auth hooks, which are ignored: ${reason}.`,
|
|
444
|
-
);
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
if (provider) {
|
|
448
|
-
warn(
|
|
449
|
-
`Frontend module ${module.id} registers auth hooks, which are ignored: ${provider.id} (priority ${provider.priority ?? 0}) provides them.`,
|
|
450
|
-
);
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
provider = {
|
|
454
|
-
id: module.id,
|
|
455
|
-
priority: module.priority ?? 0,
|
|
456
|
-
signInPath: auth.signInPath,
|
|
457
|
-
signInExempt: auth.signInExempt?.map(compilePathPattern),
|
|
458
|
-
refreshSession: auth.refreshSession,
|
|
459
|
-
publicSession: auth.publicSession,
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
return provider;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
410
|
/**
|
|
467
411
|
* The route rules: of two declarations of the same pattern, the module of
|
|
468
412
|
* higher priority keeps it, with a warning.
|
|
@@ -482,18 +426,22 @@ function resolveRules(modules, warn) {
|
|
|
482
426
|
continue;
|
|
483
427
|
}
|
|
484
428
|
for (const [pattern, rule] of Object.entries(rules)) {
|
|
485
|
-
const
|
|
429
|
+
const compiled = compilePathPattern(pattern);
|
|
486
430
|
const current = kept.get(pattern);
|
|
487
|
-
|
|
431
|
+
const reason =
|
|
432
|
+
ruleRejection(pattern, rule) ??
|
|
433
|
+
(current &&
|
|
434
|
+
`${current.id} (priority ${current.priority}) declares it`);
|
|
435
|
+
if (!compiled || reason) {
|
|
488
436
|
warn(
|
|
489
|
-
`Frontend module ${module.id} declares the route rule "${pattern}", which is ignored: ${reason
|
|
437
|
+
`Frontend module ${module.id} declares the route rule "${pattern}", which is ignored: ${reason}.`,
|
|
490
438
|
);
|
|
491
439
|
continue;
|
|
492
440
|
}
|
|
493
441
|
kept.set(pattern, {
|
|
494
442
|
id: module.id,
|
|
495
443
|
priority: module.priority ?? 0,
|
|
496
|
-
pattern:
|
|
444
|
+
pattern: compiled,
|
|
497
445
|
rule,
|
|
498
446
|
});
|
|
499
447
|
}
|