@antelopejs/dms-frontend 0.9.2 → 0.10.0
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 +18 -24
- package/dist/commands/build-action.js +3 -2
- package/dist/commands/dev-action.js +2 -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 +3 -0
- package/dist/derived-outputs.js +3 -3
- package/dist/discovery.js +17 -6
- package/dist/entry-weight.js +15 -0
- package/dist/fs-sync.js +7 -4
- 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/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 +2 -1
- package/dist/locale-files.js +4 -0
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +44 -31
- 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/private-values.d.ts +1 -1
- package/dist/private-values.js +5 -3
- package/dist/project-modules.js +13 -8
- 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 +44 -60
- package/dist/verify-source-sources.js +49 -11
- package/dist/workspace-setup.js +1 -1
- package/package.json +6 -4
- package/skills/dms-frontend/SKILL.md +1 -1
- package/templates/vue/app-runtime.ts +90 -16
- package/templates/vue/client-server-guard.mjs +16 -0
- package/templates/vue/compress-assets.mjs +9 -1
- package/templates/vue/entry-weight.mjs +22 -0
- 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/globals.d.ts +5 -0
- package/templates/vue/head-order.mjs +7 -0
- package/templates/vue/icon-bundle.ts +31 -0
- package/templates/vue/icon-scan.mjs +4 -0
- package/templates/vue/message-subset.ts +65 -0
- package/templates/vue/network-error.ts +4 -0
- package/templates/vue/server/auth/backend.mjs +41 -4
- package/templates/vue/server/auth/client-ip.mjs +52 -4
- package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
- package/templates/vue/server/auth/session.mjs +81 -2
- package/templates/vue/server/auth-hooks.mjs +15 -5
- package/templates/vue/server/backend-proxies.mjs +65 -9
- 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 +53 -11
- package/templates/vue/server/client-manifest.mjs +23 -1
- package/templates/vue/server/content.mjs +18 -1
- package/templates/vue/server/dev-private-files.mjs +17 -1
- package/templates/vue/server/dev-styles.mjs +38 -0
- package/templates/vue/server/email.mjs +9 -3
- package/templates/vue/server/favicon.mjs +3 -0
- package/templates/vue/server/generated-files.mjs +211 -0
- package/templates/vue/server/homepage.mjs +21 -9
- package/templates/vue/server/html-tag.mjs +4 -0
- package/templates/vue/server/icons.mjs +80 -7
- package/templates/vue/server/in-place-render.mjs +35 -0
- package/templates/vue/server/inertia.mjs +61 -2
- package/templates/vue/server/module-event.mjs +98 -17
- package/templates/vue/server/module-route-types.mjs +78 -0
- package/templates/vue/server/module-routes.mjs +138 -31
- package/templates/vue/server/module-server.mjs +98 -27
- package/templates/vue/server/module-testing.mjs +14 -5
- package/templates/vue/server/page-props.mjs +92 -2
- package/templates/vue/server/page-redirects.mjs +37 -0
- package/templates/vue/server/path-patterns.mjs +13 -1
- package/templates/vue/server/proxy-compression.mjs +22 -0
- package/templates/vue/server/proxy-headers.mjs +11 -1
- package/templates/vue/server/proxy-redirects.mjs +20 -5
- package/templates/vue/server/proxy-request.mjs +57 -0
- package/templates/vue/server/proxy-transfers.mjs +33 -5
- package/templates/vue/server/public-endpoints.mjs +138 -23
- package/templates/vue/server/public-options.mjs +20 -6
- package/templates/vue/server/request-failure.mjs +23 -0
- package/templates/vue/server/request-routing.mjs +26 -9
- package/templates/vue/server/request-target.mjs +9 -3
- package/templates/vue/server/route-rules.mjs +39 -15
- package/templates/vue/server/ssr-module.mjs +32 -0
- package/templates/vue/server/static-files.mjs +24 -1
- package/templates/vue/server/type-guards.mjs +71 -0
- package/templates/vue/server/vite-errors.mjs +32 -5
- package/templates/vue/server/web-body.mjs +3 -0
- package/templates/vue/server-module.ts +10 -1
- package/templates/vue/server.mjs +132 -148
- package/templates/vue/sfc-global-types.mjs +112 -13
- package/templates/vue/ssr-renderer.ts +12 -3
- package/templates/vue/ui-inertia-page.mjs +16 -1
- package/templates/vue/ui-kbd.mjs +2 -1
- package/templates/vue/ui-lazy-icons.mjs +44 -0
- package/templates/vue/ui-request-state.mjs +2 -1
- package/templates/vue/vite.config.ts +2 -0
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { getIcons } from "@iconify/utils";
|
|
6
6
|
import { ICON_API_PATH } from "../icon-api.mjs";
|
|
7
7
|
import { writeContent } from "./content.mjs";
|
|
8
|
+
import { iconCollectionOf } from "./generated-files.mjs";
|
|
8
9
|
|
|
9
10
|
const WORKSPACE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
10
11
|
const JSON_TYPE = "application/json";
|
|
@@ -24,14 +25,39 @@ const COLLECTION_PACKAGES = join("node_modules", "@iconify-json");
|
|
|
24
25
|
const ICON_CACHE_CONTROL =
|
|
25
26
|
"public, max-age=604800, stale-while-revalidate=86400";
|
|
26
27
|
const ERROR_CACHE_CONTROL = "no-store";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* An Iconify icon collection: the JSON file of an `@iconify-json/<prefix>`
|
|
31
|
+
* package.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {import("./generated-files.mjs").IconCollection} IconCollection
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The answer to an icon query, to send.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {{
|
|
40
|
+
* status: number,
|
|
41
|
+
* headers: Record<string, string>,
|
|
42
|
+
* body: string
|
|
43
|
+
* }} IconAnswer
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** @type {Map<string, Promise<IconCollection | undefined>>} */
|
|
27
47
|
const collections = new Map();
|
|
28
48
|
/**
|
|
29
49
|
* Kept by renderer module: a development server evaluates a new one after a
|
|
30
50
|
* layer changes, and each must find the collections loaded before it
|
|
31
51
|
* renders.
|
|
52
|
+
*
|
|
53
|
+
* @type {WeakMap<typeof import("../ssr-renderer.ts"), Promise<void>>}
|
|
32
54
|
*/
|
|
33
55
|
const rendererCollections = new WeakMap();
|
|
34
56
|
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} prefix
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
35
61
|
function collectionSpecifiers(prefix) {
|
|
36
62
|
return [
|
|
37
63
|
`@iconify-json/${prefix}/icons.json`,
|
|
@@ -42,6 +68,10 @@ function collectionSpecifiers(prefix) {
|
|
|
42
68
|
/**
|
|
43
69
|
* The file of an icon collection from the first source installed under its
|
|
44
70
|
* name; a source not installed is skipped and the next one tried.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} root
|
|
73
|
+
* @param {string} prefix
|
|
74
|
+
* @returns {string | undefined}
|
|
45
75
|
*/
|
|
46
76
|
function resolveCollectionFile(root, prefix) {
|
|
47
77
|
const require = createRequire(join(root, "package.json"));
|
|
@@ -53,15 +83,24 @@ function resolveCollectionFile(root, prefix) {
|
|
|
53
83
|
return undefined;
|
|
54
84
|
}
|
|
55
85
|
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} root
|
|
88
|
+
* @param {string} prefix
|
|
89
|
+
* @returns {Promise<IconCollection | undefined>}
|
|
90
|
+
*/
|
|
56
91
|
async function loadCollection(root, prefix) {
|
|
57
92
|
const file = resolveCollectionFile(root, prefix);
|
|
58
93
|
if (!file) return undefined;
|
|
59
|
-
return JSON.parse(await readFile(file, "utf8"));
|
|
94
|
+
return iconCollectionOf(JSON.parse(await readFile(file, "utf8")));
|
|
60
95
|
}
|
|
61
96
|
|
|
62
97
|
/**
|
|
63
98
|
* Parsed once per collection: a collection file is several megabytes, and
|
|
64
99
|
* one page asks for icons of the same few collections many times.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} root
|
|
102
|
+
* @param {string} prefix
|
|
103
|
+
* @returns {Promise<IconCollection | undefined>}
|
|
65
104
|
*/
|
|
66
105
|
function collection(root, prefix) {
|
|
67
106
|
const key = join(root, prefix);
|
|
@@ -77,6 +116,8 @@ function collection(root, prefix) {
|
|
|
77
116
|
* Every `@iconify-json/<prefix>` collection installed in the workspace under
|
|
78
117
|
* `root`, the ones the server render draws from. The workspace hoists every
|
|
79
118
|
* dependency, a module's collections included.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} root
|
|
80
121
|
*/
|
|
81
122
|
export async function installedCollections(root) {
|
|
82
123
|
const prefixes = await readdir(join(root, COLLECTION_PACKAGES)).catch(
|
|
@@ -94,6 +135,9 @@ export async function installedCollections(root) {
|
|
|
94
135
|
* Hand a server renderer the installed collections, once per renderer module,
|
|
95
136
|
* before its first render. A collection that fails to load leaves the render
|
|
96
137
|
* with the bundled icons only.
|
|
138
|
+
*
|
|
139
|
+
* @param {typeof import("../ssr-renderer.ts")} renderer
|
|
140
|
+
* @param {string} [root]
|
|
97
141
|
*/
|
|
98
142
|
export function provideIconCollections(renderer, root = WORKSPACE_ROOT) {
|
|
99
143
|
if (!rendererCollections.has(renderer))
|
|
@@ -101,16 +145,23 @@ export function provideIconCollections(renderer, root = WORKSPACE_ROOT) {
|
|
|
101
145
|
renderer,
|
|
102
146
|
installedCollections(root)
|
|
103
147
|
.then((loaded) => renderer.registerDmsIconCollections(loaded))
|
|
104
|
-
.catch(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
148
|
+
.catch(
|
|
149
|
+
/** @param {unknown} error */ (error) =>
|
|
150
|
+
console.error(
|
|
151
|
+
"DMS icon collections not loaded; server renders draw bundled icons only",
|
|
152
|
+
error,
|
|
153
|
+
),
|
|
109
154
|
),
|
|
110
155
|
);
|
|
111
156
|
return rendererCollections.get(renderer);
|
|
112
157
|
}
|
|
113
158
|
|
|
159
|
+
/**
|
|
160
|
+
* @param {number} status
|
|
161
|
+
* @param {string} cacheControl
|
|
162
|
+
* @param {IconCollection | null | { message: string }} payload
|
|
163
|
+
* @returns {IconAnswer}
|
|
164
|
+
*/
|
|
114
165
|
function jsonResponse(status, cacheControl, payload) {
|
|
115
166
|
return {
|
|
116
167
|
status,
|
|
@@ -119,10 +170,19 @@ function jsonResponse(status, cacheControl, payload) {
|
|
|
119
170
|
};
|
|
120
171
|
}
|
|
121
172
|
|
|
173
|
+
/**
|
|
174
|
+
* @param {number} status
|
|
175
|
+
* @param {string} message
|
|
176
|
+
* @returns {IconAnswer}
|
|
177
|
+
*/
|
|
122
178
|
function errorResponse(status, message) {
|
|
123
179
|
return jsonResponse(status, ERROR_CACHE_CONTROL, { message });
|
|
124
180
|
}
|
|
125
181
|
|
|
182
|
+
/**
|
|
183
|
+
* @param {URL} url
|
|
184
|
+
* @returns {string[] | undefined}
|
|
185
|
+
*/
|
|
126
186
|
function requestedIcons(url) {
|
|
127
187
|
const names = (url.searchParams.get("icons") ?? "")
|
|
128
188
|
.split(ICON_NAME_SEPARATOR)
|
|
@@ -132,7 +192,12 @@ function requestedIcons(url) {
|
|
|
132
192
|
return [...new Set(names)];
|
|
133
193
|
}
|
|
134
194
|
|
|
135
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Whether the generated server answers `pathname` with `iconResponse`.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} pathname
|
|
199
|
+
* @returns {boolean}
|
|
200
|
+
*/
|
|
136
201
|
export function isIconRequest(pathname) {
|
|
137
202
|
return pathname.startsWith(ICON_API_PATH);
|
|
138
203
|
}
|
|
@@ -142,6 +207,11 @@ export function isIconRequest(pathname) {
|
|
|
142
207
|
* `root`: the subset of `@iconify-json/<prefix>` (or `@iconify/json`) holding
|
|
143
208
|
* the requested icons, and a `not_found` list for the others, as
|
|
144
209
|
* `api.iconify.design` does. Returns the status, headers and body to send.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} root
|
|
212
|
+
* @param {string | undefined} method
|
|
213
|
+
* @param {URL} url
|
|
214
|
+
* @returns {Promise<IconAnswer>}
|
|
145
215
|
*/
|
|
146
216
|
export async function iconResponse(root, method, url) {
|
|
147
217
|
if (!READ_METHODS.has(method)) {
|
|
@@ -163,6 +233,9 @@ export async function iconResponse(root, method, url) {
|
|
|
163
233
|
/**
|
|
164
234
|
* Serve an Iconify API query from the collections installed in the workspace,
|
|
165
235
|
* the same way under `ajs dms dev` and `ajs dms start`: both run this server.
|
|
236
|
+
*
|
|
237
|
+
* @param {import("node:http").IncomingMessage} request
|
|
238
|
+
* @param {import("node:http").ServerResponse} response
|
|
166
239
|
*/
|
|
167
240
|
export async function handleIcons(request, response) {
|
|
168
241
|
const url = new URL(request.url, "http://frontend.local");
|
|
@@ -9,6 +9,11 @@ const IN_PLACE_RENDERS = 2;
|
|
|
9
9
|
/**
|
|
10
10
|
* The key of a backend read a later render of the same request may reuse: a
|
|
11
11
|
* plain JSON `GET`, without hooks that would expect to see the response.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} path
|
|
14
|
+
* @param {import("ofetch").FetchOptions} options
|
|
15
|
+
* @param {Headers} headers
|
|
16
|
+
* @returns {string | undefined}
|
|
12
17
|
*/
|
|
13
18
|
function reusableReadKey(path, options, headers) {
|
|
14
19
|
const method = (options.method ?? "GET").toUpperCase();
|
|
@@ -23,6 +28,13 @@ function reusableReadKey(path, options, headers) {
|
|
|
23
28
|
]);
|
|
24
29
|
}
|
|
25
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The reads of a request's server renders, with the switch that lets a later
|
|
33
|
+
* render reuse the completed ones.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {((path: string, options?: import("ofetch").FetchOptions) => Promise<unknown>) & { reuseCompletedReads: () => void }} ServerReads
|
|
36
|
+
*/
|
|
37
|
+
|
|
26
38
|
/**
|
|
27
39
|
* The backend reads of a request's server renders, sent with the request's
|
|
28
40
|
* `backendHeaders()` and the read's own. After `reuseCompletedReads()`, a later
|
|
@@ -31,10 +43,18 @@ function reusableReadKey(path, options, headers) {
|
|
|
31
43
|
*
|
|
32
44
|
* A read returns a copy: the render that read it may still change the object it
|
|
33
45
|
* got. A body that cannot be copied is read again.
|
|
46
|
+
*
|
|
47
|
+
* @param {() => HeadersInit} backendHeaders
|
|
48
|
+
* @returns {ServerReads}
|
|
34
49
|
*/
|
|
35
50
|
export function serverReads(backendHeaders) {
|
|
51
|
+
/** @type {Map<string, unknown>} */
|
|
36
52
|
const completed = new Map();
|
|
37
53
|
let reuse = false;
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} path
|
|
56
|
+
* @param {import("ofetch").FetchOptions} [options]
|
|
57
|
+
*/
|
|
38
58
|
const read = (path, options = {}) => {
|
|
39
59
|
const headers = new Headers(backendHeaders());
|
|
40
60
|
new Headers(options.headers).forEach((value, name) => {
|
|
@@ -43,6 +63,7 @@ export function serverReads(backendHeaders) {
|
|
|
43
63
|
const key = reusableReadKey(path, options, headers);
|
|
44
64
|
if (reuse && key && completed.has(key))
|
|
45
65
|
return Promise.resolve(structuredClone(completed.get(key)));
|
|
66
|
+
/** @type {Promise<unknown>} */
|
|
46
67
|
const reading = ofetch(path, {
|
|
47
68
|
...options,
|
|
48
69
|
baseURL: process.env.DMS_API_BASE_URL,
|
|
@@ -67,6 +88,11 @@ export function serverReads(backendHeaders) {
|
|
|
67
88
|
* Whether a document request renders `location` in place of redirecting to
|
|
68
89
|
* it: a `GET` whose page only completes its query (`?workspace=`), on the
|
|
69
90
|
* same path. Any other redirect keeps its own response.
|
|
91
|
+
*
|
|
92
|
+
* @param {import("node:http").IncomingMessage} request
|
|
93
|
+
* @param {string | undefined} from
|
|
94
|
+
* @param {string} location
|
|
95
|
+
* @returns {boolean}
|
|
70
96
|
*/
|
|
71
97
|
function rendersInPlace(request, from, location) {
|
|
72
98
|
if (request.method !== "GET" || location.includes("#")) return false;
|
|
@@ -87,6 +113,15 @@ function rendersInPlace(request, from, location) {
|
|
|
87
113
|
*
|
|
88
114
|
* A page that completes its query as it renders is rendered at that query in
|
|
89
115
|
* the same request, rather than answered with a redirect.
|
|
116
|
+
*
|
|
117
|
+
* @template {{ url: string | undefined, props: unknown }} Page
|
|
118
|
+
* @template {{ redirect?: string }} Rendered
|
|
119
|
+
* @param {import("node:http").IncomingMessage} request
|
|
120
|
+
* @param {Page} page
|
|
121
|
+
* @param {ServerReads} fetch
|
|
122
|
+
* @param {(page: Page) => Promise<Rendered>} render
|
|
123
|
+
* @param {(props: Page["props"], url: URL) => Promise<Page["props"]>} propsAt
|
|
124
|
+
* @returns {Promise<{ rendered: Rendered, served: Page }>}
|
|
90
125
|
*/
|
|
91
126
|
export async function renderInPlace(request, page, fetch, render, propsAt) {
|
|
92
127
|
let rendered = await render(page);
|
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
const CLIENT_MANIFEST_PATH = fileURLToPath(
|
|
6
6
|
new URL("../dist/client/.vite/manifest.json", import.meta.url),
|
|
7
7
|
);
|
|
8
|
+
/** @type {string | undefined} */
|
|
8
9
|
let productionAssetVersion;
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -27,6 +28,10 @@ export function assetVersion() {
|
|
|
27
28
|
return productionAssetVersion;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* @param {InertiaPage} page
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
30
35
|
export function inertiaAppHtml(page) {
|
|
31
36
|
const serialized = JSON.stringify(page).replaceAll("/", "\\/");
|
|
32
37
|
return `<script data-page="app" type="application/json">${serialized}</script><div id="app"></div>`;
|
|
@@ -35,6 +40,32 @@ export function inertiaAppHtml(page) {
|
|
|
35
40
|
/** The one Inertia component every DMS page renders through. */
|
|
36
41
|
export const DMS_PAGE_COMPONENT = "DmsDynamicPage";
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The once props a page declares, by key: the prop each stands for, and when
|
|
45
|
+
* the browser's copy expires (`null`: never).
|
|
46
|
+
*
|
|
47
|
+
* @typedef {Record<string, { prop: string, expiresAt: number | null }>} OnceProps
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The Inertia page object of a visit.
|
|
52
|
+
*
|
|
53
|
+
* @typedef {{
|
|
54
|
+
* component: string,
|
|
55
|
+
* props: Record<string, unknown>,
|
|
56
|
+
* url: string | undefined,
|
|
57
|
+
* version: string,
|
|
58
|
+
* onceProps?: OnceProps
|
|
59
|
+
* }} InertiaPage
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string | undefined} url
|
|
64
|
+
* @param {Record<string, unknown> | undefined} props
|
|
65
|
+
* @param {string} [version]
|
|
66
|
+
* @param {OnceProps} [onceProps]
|
|
67
|
+
* @returns {InertiaPage}
|
|
68
|
+
*/
|
|
38
69
|
export function createInertiaPage(
|
|
39
70
|
url,
|
|
40
71
|
props,
|
|
@@ -51,6 +82,11 @@ export function createInertiaPage(
|
|
|
51
82
|
return page;
|
|
52
83
|
}
|
|
53
84
|
|
|
85
|
+
/**
|
|
86
|
+
* @param {import("node:http").IncomingMessage} request
|
|
87
|
+
* @param {string} name
|
|
88
|
+
* @returns {string[]}
|
|
89
|
+
*/
|
|
54
90
|
function headerList(request, name) {
|
|
55
91
|
return String(request.headers[name] ?? "")
|
|
56
92
|
.split(",")
|
|
@@ -63,6 +99,9 @@ function headerList(request, name) {
|
|
|
63
99
|
* A partial reload (`router.reload({ only, except })`) of the page on screen
|
|
64
100
|
* names them: a nested path selects its top-level prop, which is sent whole,
|
|
65
101
|
* and only a top-level name excludes one.
|
|
102
|
+
*
|
|
103
|
+
* @param {import("node:http").IncomingMessage} request
|
|
104
|
+
* @returns {{ partial: boolean, wants: (prop: string) => boolean }}
|
|
66
105
|
*/
|
|
67
106
|
export function requestedProps(request) {
|
|
68
107
|
const partial =
|
|
@@ -81,11 +120,16 @@ export function requestedProps(request) {
|
|
|
81
120
|
};
|
|
82
121
|
}
|
|
83
122
|
|
|
84
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* The once props the browser already holds, unexpired, by key.
|
|
125
|
+
*
|
|
126
|
+
* @param {import("node:http").IncomingMessage} request
|
|
127
|
+
*/
|
|
85
128
|
export function heldOnceProps(request) {
|
|
86
129
|
return new Set(headerList(request, "x-inertia-except-once-props"));
|
|
87
130
|
}
|
|
88
131
|
|
|
132
|
+
/** @param {string} [version] */
|
|
89
133
|
export function inertiaHeaders(version = assetVersion()) {
|
|
90
134
|
return {
|
|
91
135
|
"cache-control": PRIVATE_CACHE_CONTROL,
|
|
@@ -105,6 +149,11 @@ export function htmlHeaders() {
|
|
|
105
149
|
};
|
|
106
150
|
}
|
|
107
151
|
|
|
152
|
+
/**
|
|
153
|
+
* @param {import("node:http").IncomingMessage} request
|
|
154
|
+
* @param {import("node:http").ServerResponse} response
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
108
157
|
export function handleAssetVersionMismatch(request, response) {
|
|
109
158
|
if (
|
|
110
159
|
!request.headers["x-inertia"] ||
|
|
@@ -121,7 +170,12 @@ export function handleAssetVersionMismatch(request, response) {
|
|
|
121
170
|
return true;
|
|
122
171
|
}
|
|
123
172
|
|
|
124
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* A page visit: a document or Inertia request, as opposed to an API call.
|
|
175
|
+
*
|
|
176
|
+
* @param {import("node:http").IncomingMessage} request
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
125
179
|
export function isFrontendVisit(request) {
|
|
126
180
|
if (request.method !== "GET") return false;
|
|
127
181
|
return (
|
|
@@ -130,6 +184,11 @@ export function isFrontendVisit(request) {
|
|
|
130
184
|
);
|
|
131
185
|
}
|
|
132
186
|
|
|
187
|
+
/**
|
|
188
|
+
* @param {import("node:http").IncomingMessage} request
|
|
189
|
+
* @param {import("node:http").ServerResponse} response
|
|
190
|
+
* @param {string} location
|
|
191
|
+
*/
|
|
133
192
|
export function redirectFrontendVisit(request, response, location) {
|
|
134
193
|
const vary = { vary: "X-Inertia" };
|
|
135
194
|
if (!request.headers["x-inertia"]) {
|
|
@@ -23,14 +23,40 @@ import {
|
|
|
23
23
|
relayedLocation,
|
|
24
24
|
} from "./proxy-redirects.mjs";
|
|
25
25
|
import { backendUrl } from "./request-target.mjs";
|
|
26
|
+
import {
|
|
27
|
+
isFunction,
|
|
28
|
+
isNonNullObject,
|
|
29
|
+
isNumber,
|
|
30
|
+
isString,
|
|
31
|
+
} from "./type-guards.mjs";
|
|
26
32
|
import { pipeWebBody } from "./web-body.mjs";
|
|
27
33
|
|
|
34
|
+
/** @typedef {import("node:http").IncomingMessage} IncomingMessage */
|
|
35
|
+
/** @typedef {import("node:http").ServerResponse} ServerResponse */
|
|
36
|
+
/** @typedef {import("../server-module.ts").DmsServerEvent} ServerEvent */
|
|
37
|
+
/** @typedef {import("../server-module.ts").DmsSession} Session */
|
|
38
|
+
/** @typedef {(session: Session | undefined) => import("../server-module.ts").DmsPublicSession} SessionView */
|
|
39
|
+
/**
|
|
40
|
+
* What an event is made from.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} EventContext
|
|
43
|
+
* @property {IncomingMessage} request
|
|
44
|
+
* @property {ServerResponse} response
|
|
45
|
+
* @property {Pick<import("./module-route-types.mjs").ServerRoute, "bodyLimit">} route
|
|
46
|
+
* @property {Readonly<Record<string, string>>} params
|
|
47
|
+
* @property {{ id: string, options?: unknown, privateOptions?: unknown }} module
|
|
48
|
+
* @property {{ authEstablishEndpoints?: readonly string[] }} [app]
|
|
49
|
+
* @property {SessionView} [view] What a session shows a page
|
|
50
|
+
* @property {string} [authOwner] The module that owns sign-in
|
|
51
|
+
*/
|
|
52
|
+
|
|
28
53
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
29
54
|
const COOKIE_NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
30
55
|
/** The sealed cookies of the module that owns sign-in: session, accounts, flows. */
|
|
31
56
|
const RESERVED_COOKIE_PREFIX = "dms_";
|
|
32
57
|
const BODY_TOO_LARGE = "Request body too large";
|
|
33
58
|
|
|
59
|
+
/** @param {IncomingMessage} request */
|
|
34
60
|
function requestOrigin(request) {
|
|
35
61
|
const host = request.headers.host;
|
|
36
62
|
try {
|
|
@@ -40,6 +66,7 @@ function requestOrigin(request) {
|
|
|
40
66
|
}
|
|
41
67
|
}
|
|
42
68
|
|
|
69
|
+
/** @param {IncomingMessage} request */
|
|
43
70
|
function requestHeaders(request) {
|
|
44
71
|
const headers = new Headers();
|
|
45
72
|
for (const [name, value] of Object.entries(request.headers)) {
|
|
@@ -49,12 +76,17 @@ function requestHeaders(request) {
|
|
|
49
76
|
return headers;
|
|
50
77
|
}
|
|
51
78
|
|
|
52
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* The request body as a Web stream that errors past `limit` bytes.
|
|
81
|
+
*
|
|
82
|
+
* @param {IncomingMessage} request
|
|
83
|
+
* @param {number} limit
|
|
84
|
+
*/
|
|
53
85
|
function limitedBody(request, limit) {
|
|
54
86
|
let size = 0;
|
|
55
87
|
return Readable.toWeb(request).pipeThrough(
|
|
56
88
|
new TransformStream({
|
|
57
|
-
transform(chunk, controller) {
|
|
89
|
+
transform(/** @type {Uint8Array} */ chunk, controller) {
|
|
58
90
|
size += chunk.byteLength;
|
|
59
91
|
if (size > limit)
|
|
60
92
|
controller.error(new RequestBodyError(413, BODY_TOO_LARGE));
|
|
@@ -64,6 +96,10 @@ function limitedBody(request, limit) {
|
|
|
64
96
|
);
|
|
65
97
|
}
|
|
66
98
|
|
|
99
|
+
/**
|
|
100
|
+
* @param {IncomingMessage} request
|
|
101
|
+
* @param {EventContext["route"]} route
|
|
102
|
+
*/
|
|
67
103
|
function webRequest(request, route) {
|
|
68
104
|
const url = new URL(request.url, requestOrigin(request));
|
|
69
105
|
const hasBody = !["GET", "HEAD"].includes(request.method);
|
|
@@ -78,8 +114,13 @@ function webRequest(request, route) {
|
|
|
78
114
|
});
|
|
79
115
|
}
|
|
80
116
|
|
|
117
|
+
/**
|
|
118
|
+
* @param {Request} webRequestValue
|
|
119
|
+
* @returns {Promise<unknown>}
|
|
120
|
+
*/
|
|
81
121
|
async function readJson(webRequestValue) {
|
|
82
122
|
if (!webRequestValue.body) return {};
|
|
123
|
+
/** @type {Uint8Array[]} */
|
|
83
124
|
const chunks = [];
|
|
84
125
|
const reader = webRequestValue.body.getReader();
|
|
85
126
|
for (;;) {
|
|
@@ -95,8 +136,9 @@ async function readJson(webRequestValue) {
|
|
|
95
136
|
}
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
/** @param {unknown} name */
|
|
98
140
|
function checkedCookieName(name) {
|
|
99
|
-
if (
|
|
141
|
+
if (!isString(name) || !COOKIE_NAME.test(name))
|
|
100
142
|
throw new Error(`Invalid cookie name: ${String(name)}`);
|
|
101
143
|
if (name === SESSION_COOKIE)
|
|
102
144
|
throw new Error(
|
|
@@ -105,6 +147,12 @@ function checkedCookieName(name) {
|
|
|
105
147
|
return name;
|
|
106
148
|
}
|
|
107
149
|
|
|
150
|
+
/**
|
|
151
|
+
* @param {IncomingMessage} request
|
|
152
|
+
* @param {ServerResponse} response
|
|
153
|
+
* @param {SessionView | undefined} view
|
|
154
|
+
* @returns {ServerEvent["session"]}
|
|
155
|
+
*/
|
|
108
156
|
function sessionAccess(request, response, view) {
|
|
109
157
|
return {
|
|
110
158
|
read: () => readSession(request),
|
|
@@ -124,6 +172,10 @@ function sessionAccess(request, response, view) {
|
|
|
124
172
|
* module's are prefixed with its id and a dot, which no name of the owner's
|
|
125
173
|
* holds, so no module reads or overwrites another's, nor the owner's session
|
|
126
174
|
* and account cookies.
|
|
175
|
+
*
|
|
176
|
+
* @param {unknown} name
|
|
177
|
+
* @param {string} moduleId
|
|
178
|
+
* @param {string | undefined} authOwner
|
|
127
179
|
*/
|
|
128
180
|
function moduleCookieName(name, moduleId, authOwner) {
|
|
129
181
|
const checked = checkedCookieName(name);
|
|
@@ -131,15 +183,26 @@ function moduleCookieName(name, moduleId, authOwner) {
|
|
|
131
183
|
if (checked.startsWith(RESERVED_COOKIE_PREFIX))
|
|
132
184
|
throw new Error(
|
|
133
185
|
`The ${RESERVED_COOKIE_PREFIX}* cookies belong to the module that owns sign-in` +
|
|
134
|
-
`${authOwner ? ` (${authOwner})` : ""}: choose another name than ${
|
|
186
|
+
`${authOwner ? ` (${authOwner})` : ""}: choose another name than ${checked}`,
|
|
135
187
|
);
|
|
136
188
|
return `${String(moduleId).replaceAll(/[^A-Za-z0-9_-]/g, "-")}.${checked}`;
|
|
137
189
|
}
|
|
138
190
|
|
|
191
|
+
/**
|
|
192
|
+
* @param {IncomingMessage} request
|
|
193
|
+
* @param {ServerResponse} response
|
|
194
|
+
* @param {string} moduleId
|
|
195
|
+
* @param {string | undefined} authOwner
|
|
196
|
+
* @returns {ServerEvent["cookies"]}
|
|
197
|
+
*/
|
|
139
198
|
function cookieAccess(request, response, moduleId, authOwner) {
|
|
140
|
-
const named = (
|
|
199
|
+
const named = (/** @type {unknown} */ name) =>
|
|
200
|
+
moduleCookieName(name, moduleId, authOwner);
|
|
141
201
|
return {
|
|
142
|
-
readSealed: (name) =>
|
|
202
|
+
readSealed: (name, accepts) => {
|
|
203
|
+
const value = readSealedCookie(request, named(name));
|
|
204
|
+
return value !== undefined && accepts(value) ? value : undefined;
|
|
205
|
+
},
|
|
143
206
|
writeSealed: (name, value, options = {}) =>
|
|
144
207
|
writeSealedCookie(
|
|
145
208
|
response,
|
|
@@ -153,6 +216,7 @@ function cookieAccess(request, response, moduleId, authOwner) {
|
|
|
153
216
|
};
|
|
154
217
|
}
|
|
155
218
|
|
|
219
|
+
/** @param {unknown} value */
|
|
156
220
|
function frozenCopy(value) {
|
|
157
221
|
return Object.freeze(structuredClone(value ?? {}));
|
|
158
222
|
}
|
|
@@ -165,10 +229,11 @@ function frozenCopy(value) {
|
|
|
165
229
|
* the defaults every route gets once (same-origin on unsafe methods, a body
|
|
166
230
|
* limit, `no-store`). See `server-module.ts` for the types a module reads.
|
|
167
231
|
*
|
|
168
|
-
* @param context `{ request, response, route, params, module, app, view,
|
|
232
|
+
* @param {EventContext} context `{ request, response, route, params, module, app, view,
|
|
169
233
|
* authOwner }`, where `module` is `{ id, options, privateOptions }`, `app`
|
|
170
234
|
* the deployment's read-only data, `view` what a session shows a page and
|
|
171
235
|
* `authOwner` the id of the module that owns sign-in
|
|
236
|
+
* @returns {ServerEvent}
|
|
172
237
|
*/
|
|
173
238
|
export function createServerEvent(context) {
|
|
174
239
|
const { request, response, route, params } = context;
|
|
@@ -182,16 +247,20 @@ export function createServerEvent(context) {
|
|
|
182
247
|
context.module.id,
|
|
183
248
|
context.authOwner,
|
|
184
249
|
),
|
|
185
|
-
backend: (path, init = {}) =>
|
|
186
|
-
|
|
250
|
+
backend: (/** @type {string} */ path, init = {}) =>
|
|
251
|
+
backend(path, request, init),
|
|
252
|
+
backendUrl: (/** @type {string} */ path) => backendUrl(path),
|
|
187
253
|
clientIp: () => clientIp(request),
|
|
188
254
|
module: Object.freeze({
|
|
189
255
|
id: context.module.id,
|
|
190
256
|
options: frozenCopy(context.module.options),
|
|
191
257
|
privateOptions: frozenCopy(context.module.privateOptions),
|
|
192
258
|
}),
|
|
193
|
-
renderEmail: (
|
|
194
|
-
|
|
259
|
+
renderEmail: (
|
|
260
|
+
/** @type {string} */ template,
|
|
261
|
+
/** @type {Record<string, unknown>} */ props,
|
|
262
|
+
options = {},
|
|
263
|
+
) => renderEmailTemplate(template, props, options),
|
|
195
264
|
app: Object.freeze({
|
|
196
265
|
authEstablishEndpoints: Object.freeze([
|
|
197
266
|
...(context.app?.authEstablishEndpoints ?? []),
|
|
@@ -203,19 +272,26 @@ export function createServerEvent(context) {
|
|
|
203
272
|
return event;
|
|
204
273
|
}
|
|
205
274
|
|
|
275
|
+
/** @param {unknown} value */
|
|
206
276
|
function isResponse(value) {
|
|
207
277
|
return (
|
|
208
278
|
value instanceof Response ||
|
|
209
|
-
(
|
|
210
|
-
value
|
|
211
|
-
|
|
212
|
-
typeof value.headers?.get === "function" &&
|
|
279
|
+
(isNonNullObject(value) &&
|
|
280
|
+
isNumber(value.status) &&
|
|
281
|
+
isFunction(value.headers?.get) &&
|
|
213
282
|
"body" in value)
|
|
214
283
|
);
|
|
215
284
|
}
|
|
216
285
|
|
|
286
|
+
/**
|
|
287
|
+
* @param {any} result What a handler answered: any value, of which only
|
|
288
|
+
* `isResponse` looks
|
|
289
|
+
* @returns {Response}
|
|
290
|
+
*/
|
|
217
291
|
function asResponse(result) {
|
|
218
|
-
if (isResponse(result))
|
|
292
|
+
if (isResponse(result))
|
|
293
|
+
// oxlint-disable-next-line typescript/no-unsafe-return -- `isResponse` also accepts a `Response` of another realm by its members (`status`, `headers.get`, `body`), so it cannot be a `value is Response` predicate without claiming members it does not check.
|
|
294
|
+
return result;
|
|
219
295
|
if (result === undefined || result === null)
|
|
220
296
|
return new Response(null, { status: 204 });
|
|
221
297
|
return Response.json(result);
|
|
@@ -227,6 +303,11 @@ function asResponse(result) {
|
|
|
227
303
|
* answer the handler relays goes out as the proxy relays one: its `Location`
|
|
228
304
|
* on the frontend's origin when the frontend proxies its path, no cookie of
|
|
229
305
|
* another site its fetch reached.
|
|
306
|
+
*
|
|
307
|
+
* @param {IncomingMessage} request
|
|
308
|
+
* @param {ServerResponse} response
|
|
309
|
+
* @param {unknown} result
|
|
310
|
+
* @param {(path: string) => boolean} isProxiedPath
|
|
230
311
|
*/
|
|
231
312
|
async function writeServerResponse(request, response, result, isProxiedPath) {
|
|
232
313
|
const answer = asResponse(result);
|
|
@@ -261,7 +342,7 @@ async function writeServerResponse(request, response, result, isProxiedPath) {
|
|
|
261
342
|
* through reaches the server's error handling with the cookies written so
|
|
262
343
|
* far.
|
|
263
344
|
*
|
|
264
|
-
* @param context See `createServerEvent`, plus `isProxiedPath(pathname)`:
|
|
345
|
+
* @param {Omit<EventContext, "route"> & { route: import("./module-route-types.mjs").ServerRoute, isProxiedPath: (path: string) => boolean }} context See `createServerEvent`, plus `isProxiedPath(pathname)`:
|
|
265
346
|
* whether the frontend sends that path to the backend
|
|
266
347
|
*/
|
|
267
348
|
export async function answerModuleRoute(context) {
|