@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
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { isNumber, isObjectLike } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
2
4
|
const TEXT_TYPE = "text/plain; charset=utf-8";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Whether the path of `url` holds a percent-escape no decoder accepts, such as
|
|
6
8
|
* `/projects/%E0%A4%A`. Vite and the router decode the path, and throw on it.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} url
|
|
7
11
|
*/
|
|
8
12
|
export function hasMalformedPath(url) {
|
|
9
13
|
try {
|
|
@@ -14,13 +18,27 @@ export function hasMalformedPath(url) {
|
|
|
14
18
|
}
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The status an error asks to be answered with: its integer `status`, else 502.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} error
|
|
25
|
+
* @returns {number}
|
|
26
|
+
*/
|
|
27
|
+
export function failureStatus(error) {
|
|
28
|
+
const status = isObjectLike(error) ? error.status : undefined;
|
|
29
|
+
return isNumber(status) && Number.isInteger(status) ? status : 502;
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
/**
|
|
18
33
|
* Whether a failed request can no longer be answered: the client went away, or
|
|
19
34
|
* the response already started.
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} error
|
|
37
|
+
* @param {import("node:http").ServerResponse} response
|
|
20
38
|
*/
|
|
21
39
|
export function isResponseGone(error, response) {
|
|
22
40
|
return (
|
|
23
|
-
error
|
|
41
|
+
(isObjectLike(error) && error.code === "ERR_STREAM_PREMATURE_CLOSE") ||
|
|
24
42
|
response.destroyed ||
|
|
25
43
|
response.writableEnded ||
|
|
26
44
|
response.headersSent
|
|
@@ -30,6 +48,8 @@ export function isResponseGone(error, response) {
|
|
|
30
48
|
/**
|
|
31
49
|
* Answers for requests the server cannot serve at all: an unreadable URL, or
|
|
32
50
|
* a failure while it was already rendering the error page.
|
|
51
|
+
*
|
|
52
|
+
* @param {import("node:http").ServerResponse} response
|
|
33
53
|
*/
|
|
34
54
|
export function writeBadRequest(response) {
|
|
35
55
|
response.writeHead(400, { "content-type": TEXT_TYPE });
|
|
@@ -40,6 +60,9 @@ export function writeBadRequest(response) {
|
|
|
40
60
|
* The last resort once even the error page failed: a bare 500, or a dropped
|
|
41
61
|
* connection when the response already started. The server keeps running
|
|
42
62
|
* either way.
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} error
|
|
65
|
+
* @param {import("node:http").ServerResponse} response
|
|
43
66
|
*/
|
|
44
67
|
export function abandonResponse(error, response) {
|
|
45
68
|
console.error("DMS error response failed", error);
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { matchBackendProxy } from "./backend-proxies.mjs";
|
|
2
|
+
import { isIconRequest } from "./icons.mjs";
|
|
2
3
|
import { isRendererFilePath, moduleRouteMethods } from "./module-routes.mjs";
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} RoutingModules
|
|
7
|
+
* @property {import("./module-route-types.mjs").ServerRoute[]} routes
|
|
8
|
+
* @property {import("./backend-proxies.mjs").BackendProxy[]} [proxies]
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ kind: "renderer" }
|
|
12
|
+
* | { kind: "module", id: string }
|
|
13
|
+
* | { kind: "backend", proxy: import("./backend-proxies.mjs").BackendProxy }
|
|
14
|
+
* | { kind: "page" }
|
|
15
|
+
* | { kind: "not-found" }} RequestOwner
|
|
16
|
+
*/
|
|
17
|
+
|
|
4
18
|
const PAGE_METHODS = new Set(["GET", "HEAD"]);
|
|
5
19
|
|
|
6
20
|
/**
|
|
@@ -14,17 +28,21 @@ const PAGE_METHODS = new Set(["GET", "HEAD"]);
|
|
|
14
28
|
* `backendProxies`); 4. the pages, for everything else: a method other than
|
|
15
29
|
* `GET` and `HEAD` on a page path is a `404`.
|
|
16
30
|
*
|
|
17
|
-
* @param method The request's method
|
|
18
|
-
* @param pathname The request's normalized path
|
|
19
|
-
* @param modules What `loadServerModules` returned: `routes` and `proxies`
|
|
20
|
-
* @param hasRendererFile `(pathname) => boolean`, whether the renderer has a
|
|
31
|
+
* @param {string} method The request's method
|
|
32
|
+
* @param {string} pathname The request's normalized path
|
|
33
|
+
* @param {RoutingModules} modules What `loadServerModules` returned: `routes` and `proxies`
|
|
34
|
+
* @param {(pathname: string) => boolean} hasRendererFile `(pathname) => boolean`, whether the renderer has a
|
|
21
35
|
* file at that path
|
|
22
|
-
* @returns `{ kind: "renderer" }`, `{ kind: "module", id }`,
|
|
36
|
+
* @returns {RequestOwner} `{ kind: "renderer" }`, `{ kind: "module", id }`,
|
|
23
37
|
* `{ kind: "backend", proxy }`, `{ kind: "page" }` or
|
|
24
38
|
* `{ kind: "not-found" }`
|
|
25
39
|
*/
|
|
26
40
|
export function requestOwner(method, pathname, modules, hasRendererFile) {
|
|
27
|
-
if (
|
|
41
|
+
if (
|
|
42
|
+
isIconRequest(pathname) ||
|
|
43
|
+
isRendererFilePath(pathname) ||
|
|
44
|
+
hasRendererFile(pathname)
|
|
45
|
+
)
|
|
28
46
|
return { kind: "renderer" };
|
|
29
47
|
const route = modules.routes.find((candidate) =>
|
|
30
48
|
candidate.matcher.test(pathname),
|
|
@@ -41,9 +59,9 @@ export function requestOwner(method, pathname, modules, hasRendererFile) {
|
|
|
41
59
|
* `Location` on such a path can become a frontend path (see
|
|
42
60
|
* `proxy-redirects.mjs`).
|
|
43
61
|
*
|
|
44
|
-
* @param pathname A normalized path
|
|
45
|
-
* @param modules What `loadServerModules` returned: `routes` and `proxies`
|
|
46
|
-
* @param hasRendererFile `(pathname) => boolean`, whether the renderer has a
|
|
62
|
+
* @param {string} pathname A normalized path
|
|
63
|
+
* @param {RoutingModules} modules What `loadServerModules` returned: `routes` and `proxies`
|
|
64
|
+
* @param {(pathname: string) => boolean} [hasRendererFile] `(pathname) => boolean`, whether the renderer has a
|
|
47
65
|
* file at that path
|
|
48
66
|
*/
|
|
49
67
|
export function reachesBackend(
|
|
@@ -56,7 +74,11 @@ export function reachesBackend(
|
|
|
56
74
|
);
|
|
57
75
|
}
|
|
58
76
|
|
|
59
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The `x-dms-route-owner` value of an owner, for the development server.
|
|
79
|
+
*
|
|
80
|
+
* @param {RequestOwner} owner
|
|
81
|
+
*/
|
|
60
82
|
export function ownerLabel(owner) {
|
|
61
83
|
if (owner.kind === "module") return `module:${owner.id}`;
|
|
62
84
|
if (owner.kind === "backend") return `backend:${owner.proxy.path}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isString } from "./type-guards.mjs";
|
|
2
2
|
|
|
3
3
|
const FRONTEND_ORIGIN = "http://frontend.local";
|
|
4
4
|
|
|
@@ -7,9 +7,11 @@ const FRONTEND_ORIGIN = "http://frontend.local";
|
|
|
7
7
|
* query, that stays on this server once resolved. Anything else (an absolute
|
|
8
8
|
* or authority form, `*`, a path a URL parser reads as another host) is not a
|
|
9
9
|
* request a browser sends to the frontend.
|
|
10
|
+
*
|
|
11
|
+
* @param {string | undefined} target
|
|
10
12
|
*/
|
|
11
13
|
export function isOriginForm(target) {
|
|
12
|
-
if (
|
|
14
|
+
if (!isString(target) || !target.startsWith("/")) return false;
|
|
13
15
|
try {
|
|
14
16
|
return new URL(target, FRONTEND_ORIGIN).origin === FRONTEND_ORIGIN;
|
|
15
17
|
} catch {
|
|
@@ -22,12 +24,12 @@ export function isOriginForm(target) {
|
|
|
22
24
|
* and query on the backend's origin. The path is set, not resolved, so a path
|
|
23
25
|
* normalizing to `//host` (`/x/..//host`) stays a path on the backend.
|
|
24
26
|
*
|
|
25
|
-
* @param target The request target, origin-form
|
|
26
|
-
* @param backendBaseUrl The backend's base URL
|
|
27
|
+
* @param {string} target The request target, origin-form
|
|
28
|
+
* @param {string} [backendBaseUrl] The backend's base URL
|
|
27
29
|
*/
|
|
28
30
|
export function backendUrl(
|
|
29
31
|
target,
|
|
30
|
-
backendBaseUrl = process.env.DMS_API_BASE_URL,
|
|
32
|
+
backendBaseUrl = process.env.DMS_API_BASE_URL ?? "",
|
|
31
33
|
) {
|
|
32
34
|
const { pathname, search } = new URL(target, FRONTEND_ORIGIN);
|
|
33
35
|
const url = new URL(new URL(backendBaseUrl).origin);
|
|
@@ -37,17 +39,34 @@ export function backendUrl(
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
+
* A request a server received, whose `url` and `method` Node always sets
|
|
43
|
+
* (`isServerRequest`).
|
|
44
|
+
*
|
|
45
|
+
* @typedef {import("node:http").IncomingMessage & { url: string, method: string }} ServerRequest
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @template {import("node:http").IncomingMessage} Request
|
|
50
|
+
* @param {Request} request
|
|
51
|
+
* @returns {request is Request & ServerRequest}
|
|
52
|
+
*/
|
|
53
|
+
export function isServerRequest(request) {
|
|
54
|
+
return isString(request.url) && isString(request.method);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the request target is origin-form, the only kind the server routes:
|
|
59
|
+
* a request that fails it is answered 400 before any route reads it.
|
|
42
60
|
*
|
|
43
61
|
* The request target as the server reads it. Node hands `request.url` over
|
|
44
62
|
* verbatim, so it is not always a path: `//host/x`, `/\host/x` and the absolute
|
|
45
63
|
* form `http://host/x` all name another host once resolved, which turned the
|
|
46
64
|
* proxy into an open one. Every routing decision and the URL sent to the
|
|
47
65
|
* backend read the same normalized path from here.
|
|
66
|
+
*
|
|
67
|
+
* @param {import("node:http").IncomingMessage} request
|
|
68
|
+
* @returns {request is ServerRequest}
|
|
48
69
|
*/
|
|
49
|
-
export function
|
|
50
|
-
|
|
51
|
-
writeBadRequest(response);
|
|
52
|
-
return true;
|
|
70
|
+
export function hasOriginFormTarget(request) {
|
|
71
|
+
return isOriginForm(request.url) && isString(request.method);
|
|
53
72
|
}
|
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import { compilePathPattern } from "./path-patterns.mjs";
|
|
1
|
+
import { compilePathPattern, PATH_PATTERN_FORMS } from "./path-patterns.mjs";
|
|
2
|
+
import {
|
|
3
|
+
isNonNullObject,
|
|
4
|
+
isNumber,
|
|
5
|
+
isObjectLike,
|
|
6
|
+
isRecord,
|
|
7
|
+
isString,
|
|
8
|
+
} from "./type-guards.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("../server-module.ts").DmsRouteRule} RouteRule */
|
|
11
|
+
/** @typedef {RouteRule & { headers: Record<string, string> }} MergedRouteRule */
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} ResolvedRouteRule
|
|
14
|
+
* @property {string} id The declaring module
|
|
15
|
+
* @property {number} priority The declaring module's priority
|
|
16
|
+
* @property {import("./path-patterns.mjs").CompiledPathPattern} pattern
|
|
17
|
+
* @property {RouteRule} rule
|
|
18
|
+
*/
|
|
2
19
|
|
|
3
20
|
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
21
|
+
/**
|
|
22
|
+
* Whether a header value holds a control character other than a tab: a CR
|
|
23
|
+
* or an LF would split the answer.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} value
|
|
26
|
+
*/
|
|
6
27
|
function hasControlCharacter(value) {
|
|
7
|
-
return
|
|
28
|
+
return Array.from(value).some((character) => {
|
|
8
29
|
const code = character.charCodeAt(0);
|
|
9
30
|
return (code < 0x20 && code !== 0x09) || code === 0x7f;
|
|
10
31
|
});
|
|
@@ -20,19 +41,21 @@ const RESERVED_HEADERS = new Set([
|
|
|
20
41
|
"x-inertia-version",
|
|
21
42
|
]);
|
|
22
43
|
|
|
44
|
+
/** @param {unknown} value */
|
|
23
45
|
function isSeconds(value) {
|
|
24
|
-
return Number.isSafeInteger(value) && value >= 0;
|
|
46
|
+
return isNumber(value) && Number.isSafeInteger(value) && value >= 0;
|
|
25
47
|
}
|
|
26
48
|
|
|
49
|
+
/** @param {unknown} headers */
|
|
27
50
|
function headersRejection(headers) {
|
|
28
51
|
if (headers === undefined) return undefined;
|
|
29
|
-
if (
|
|
52
|
+
if (!isNonNullObject(headers) || Array.isArray(headers))
|
|
30
53
|
return "headers is not an object";
|
|
31
54
|
for (const [name, value] of Object.entries(headers)) {
|
|
32
55
|
const lower = name.toLowerCase();
|
|
33
56
|
if (
|
|
34
57
|
!HEADER_NAME.test(lower) ||
|
|
35
|
-
|
|
58
|
+
!isString(value) ||
|
|
36
59
|
hasControlCharacter(value)
|
|
37
60
|
)
|
|
38
61
|
return `"${name}" is not a header with a text value`;
|
|
@@ -42,11 +65,11 @@ function headersRejection(headers) {
|
|
|
42
65
|
return undefined;
|
|
43
66
|
}
|
|
44
67
|
|
|
68
|
+
/** @param {unknown} cache */
|
|
45
69
|
function cacheRejection(cache) {
|
|
46
70
|
if (cache === undefined || cache === false) return undefined;
|
|
47
71
|
if (
|
|
48
|
-
|
|
49
|
-
cache === null ||
|
|
72
|
+
!isRecord(cache) ||
|
|
50
73
|
cache.public !== true ||
|
|
51
74
|
!isSeconds(cache.maxAge) ||
|
|
52
75
|
(cache.staleWhileRevalidate !== undefined &&
|
|
@@ -62,11 +85,14 @@ function cacheRejection(cache) {
|
|
|
62
85
|
* Route rules the modules declare in their `dms.server.ts`: how a page is
|
|
63
86
|
* rendered and which headers its document and Inertia answers carry. They
|
|
64
87
|
* change rendering and caching, never who may see a page.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} pattern
|
|
90
|
+
* @param {unknown} rule
|
|
65
91
|
*/
|
|
66
92
|
export function ruleRejection(pattern, rule) {
|
|
67
93
|
if (!compilePathPattern(pattern))
|
|
68
|
-
return
|
|
69
|
-
if (
|
|
94
|
+
return `the pattern is not ${PATH_PATTERN_FORMS}`;
|
|
95
|
+
if (!isRecord(rule)) return "it is not an object";
|
|
70
96
|
if (rule.ssr !== undefined && rule.ssr !== false)
|
|
71
97
|
return "ssr is either left out or false";
|
|
72
98
|
return headersRejection(rule.headers) ?? cacheRejection(rule.cache);
|
|
@@ -77,10 +103,12 @@ export function ruleRejection(pattern, rule) {
|
|
|
77
103
|
* specific pattern: a more specific rule overrides `ssr`, `cache` and each
|
|
78
104
|
* header it sets.
|
|
79
105
|
*
|
|
80
|
-
* @param rules The resolved rules, `{ pattern, rule }` each
|
|
81
|
-
* @param pathname The page's path
|
|
106
|
+
* @param {readonly ResolvedRouteRule[]} rules The resolved rules, `{ pattern, rule }` each
|
|
107
|
+
* @param {string} pathname The page's path
|
|
108
|
+
* @returns {MergedRouteRule}
|
|
82
109
|
*/
|
|
83
110
|
export function routeRuleFor(rules, pathname) {
|
|
111
|
+
/** @type {MergedRouteRule} */
|
|
84
112
|
const merged = { headers: {} };
|
|
85
113
|
const matching = rules
|
|
86
114
|
.filter(({ pattern }) => pattern.test(pathname))
|
|
@@ -95,6 +123,7 @@ export function routeRuleFor(rules, pathname) {
|
|
|
95
123
|
return merged;
|
|
96
124
|
}
|
|
97
125
|
|
|
126
|
+
/** @param {{ maxAge: number, staleWhileRevalidate?: number }} cache */
|
|
98
127
|
function publicCacheControl(cache) {
|
|
99
128
|
const stale =
|
|
100
129
|
cache.staleWhileRevalidate === undefined
|
|
@@ -109,10 +138,10 @@ function publicCacheControl(cache) {
|
|
|
109
138
|
* content language: the answer then holds nobody's data, and `Vary: Cookie`
|
|
110
139
|
* keeps a shared cache from serving it to a visitor who has some.
|
|
111
140
|
*
|
|
112
|
-
* @param rule What `routeRuleFor` returned
|
|
113
|
-
* @param request The page request
|
|
114
|
-
* @param route The page's route, as the backend described it
|
|
115
|
-
* @param status The answer's status: only a 200 is cached
|
|
141
|
+
* @param {MergedRouteRule} rule What `routeRuleFor` returned
|
|
142
|
+
* @param {import("node:http").IncomingMessage} request The page request
|
|
143
|
+
* @param {unknown} route The page's route, as the backend described it: any JSON value, only `publicAccess` is read
|
|
144
|
+
* @param {number} status The answer's status: only a 200 is cached
|
|
116
145
|
*/
|
|
117
146
|
export function ruleHeaders(rule, request, route, status) {
|
|
118
147
|
const headers = { ...rule.headers };
|
|
@@ -122,7 +151,8 @@ export function ruleHeaders(rule, request, route, status) {
|
|
|
122
151
|
request.method === "GET" &&
|
|
123
152
|
!request.headers.cookie &&
|
|
124
153
|
!request.headers["x-content-language"] &&
|
|
125
|
-
route
|
|
154
|
+
isObjectLike(route) &&
|
|
155
|
+
route.publicAccess === true
|
|
126
156
|
) {
|
|
127
157
|
headers["cache-control"] = publicCacheControl(rule.cache);
|
|
128
158
|
headers.vary = "X-Inertia, Cookie";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as icons from "./icons.mjs";
|
|
2
|
+
|
|
3
|
+
/** @typedef {import("vite").ViteDevServer} ViteDevServer */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A URL, not a path: on Windows, `import()` reads `C:\...` as a URL whose
|
|
7
|
+
* scheme is `c:` and refuses it.
|
|
8
|
+
*/
|
|
9
|
+
const BUILT_SSR_RENDERER_URL = new URL(
|
|
10
|
+
"../dist/ssr/ssr-renderer.js",
|
|
11
|
+
import.meta.url,
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/** @type {Promise<typeof import("../ssr-renderer.ts")> | undefined} */
|
|
15
|
+
let productionSsrRenderer;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The SSR renderer of the workspace: the development server's module, else
|
|
19
|
+
* the built bundle, loaded once.
|
|
20
|
+
*
|
|
21
|
+
* @param {ViteDevServer | undefined} devServer
|
|
22
|
+
* @returns {Promise<typeof import("../ssr-renderer.ts")>}
|
|
23
|
+
*/
|
|
24
|
+
export async function ssrRenderer(devServer) {
|
|
25
|
+
/** @type {typeof import("../ssr-renderer.ts")} */
|
|
26
|
+
// oxlint-disable-next-line typescript/no-unsafe-assignment -- `ssrLoadModule` types every module `Record<string, any>` and the production import takes a URL computed at run time; both load this template's own `ssr-renderer.ts`, the type named above.
|
|
27
|
+
const renderer = devServer
|
|
28
|
+
? await devServer.ssrLoadModule("/ssr-renderer.ts")
|
|
29
|
+
: await (productionSsrRenderer ??= import(BUILT_SSR_RENDERER_URL.href));
|
|
30
|
+
await icons.provideIconCollections(renderer);
|
|
31
|
+
return renderer;
|
|
32
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
2
2
|
import { extname } from "node:path";
|
|
3
|
+
import { acceptedCodings } from "./content.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* What a build or a module's `public/` directory may hold. A type missing
|
|
6
7
|
* here went out as `application/octet-stream`: fonts among them, which a
|
|
7
8
|
* browser then reports as a mismatch against the `font/woff2` it preloaded.
|
|
9
|
+
*
|
|
10
|
+
* @type {Record<string, string | undefined>}
|
|
8
11
|
*/
|
|
9
12
|
const MIME_TYPES = {
|
|
10
13
|
".avif": "image/avif",
|
|
@@ -46,32 +49,51 @@ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
|
46
49
|
*/
|
|
47
50
|
const REVALIDATED_CACHE = "no-cache";
|
|
48
51
|
|
|
52
|
+
/** The suffix the build gives the copy of a file in each coding. */
|
|
53
|
+
const VARIANT_SUFFIXES = { br: ".br", gzip: ".gz" };
|
|
54
|
+
|
|
55
|
+
/** @param {string} path */
|
|
49
56
|
const isFile = (path) => existsSync(path) && statSync(path).isFile();
|
|
50
57
|
|
|
51
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* The precompressed variant of `file` the client wants most among those the
|
|
60
|
+
* build wrote, or `file` itself.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} file
|
|
63
|
+
* @param {import("node:http").IncomingMessage} request
|
|
64
|
+
*/
|
|
52
65
|
function acceptedVariant(file, request) {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
const encoding = acceptedCodings(request).find((coding) =>
|
|
67
|
+
isFile(`${file}${VARIANT_SUFFIXES[coding]}`),
|
|
68
|
+
);
|
|
69
|
+
return encoding
|
|
70
|
+
? { file: `${file}${VARIANT_SUFFIXES[encoding]}`, encoding }
|
|
71
|
+
: { file, encoding: undefined };
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
/**
|
|
62
75
|
* Weak, as the bytes of one representation are not compared: the size and
|
|
63
76
|
* modification time of the file sent, so each encoding has its own.
|
|
77
|
+
*
|
|
78
|
+
* @param {import("node:fs").Stats} stats
|
|
79
|
+
* @returns {string}
|
|
64
80
|
*/
|
|
65
81
|
function entityTag(stats) {
|
|
66
82
|
return `W/"${stats.size.toString(16)}-${Math.floor(stats.mtimeMs).toString(16)}"`;
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
/** @param {string} tag */
|
|
69
86
|
const opaqueTag = (tag) => tag.trim().replace(/^W\//, "");
|
|
70
87
|
|
|
71
88
|
/**
|
|
72
89
|
* RFC 9110 §13.1.2 and §13.1.3: `If-None-Match` first, the date otherwise.
|
|
73
90
|
*
|
|
74
91
|
* The header has a one-second resolution.
|
|
92
|
+
*
|
|
93
|
+
* @param {import("node:http").IncomingMessage} request
|
|
94
|
+
* @param {string} tag
|
|
95
|
+
* @param {import("node:fs").Stats} stats
|
|
96
|
+
* @returns {boolean}
|
|
75
97
|
*/
|
|
76
98
|
function isNotModified(request, tag, stats) {
|
|
77
99
|
const noneMatch = request.headers["if-none-match"];
|
|
@@ -92,11 +114,17 @@ function isNotModified(request, tag, stats) {
|
|
|
92
114
|
* Send `file`, a file the caller already resolved and checked (see
|
|
93
115
|
* `publicBuildFile`), as the variant the client accepts, or answer 304 when the
|
|
94
116
|
* client's copy is current. `immutable` for a file named after its content.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} file
|
|
119
|
+
* @param {import("node:http").IncomingMessage} request
|
|
120
|
+
* @param {import("node:http").ServerResponse} response
|
|
121
|
+
* @param {{ immutable: boolean }} options
|
|
95
122
|
*/
|
|
96
123
|
export function serveStaticFile(file, request, response, { immutable }) {
|
|
97
124
|
const selected = acceptedVariant(file, request);
|
|
98
125
|
const stats = statSync(selected.file);
|
|
99
126
|
const tag = entityTag(stats);
|
|
127
|
+
/** @type {import("node:http").OutgoingHttpHeaders} */
|
|
100
128
|
const headers = {
|
|
101
129
|
"cache-control": immutable ? IMMUTABLE_CACHE : REVALIDATED_CACHE,
|
|
102
130
|
etag: tag,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `value` is a string primitive, a boxed `String` excluded.
|
|
3
|
+
*
|
|
4
|
+
* @param {unknown} value The value to test
|
|
5
|
+
* @returns {value is string} Whether `typeof value` is `"string"`
|
|
6
|
+
*/
|
|
7
|
+
export function isString(value) {
|
|
8
|
+
return typeof value === "string";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether `value` is a number primitive, `NaN` and the infinities included.
|
|
13
|
+
*
|
|
14
|
+
* @param {unknown} value The value to test
|
|
15
|
+
* @returns {value is number} Whether `typeof value` is `"number"`
|
|
16
|
+
*/
|
|
17
|
+
export function isNumber(value) {
|
|
18
|
+
return typeof value === "number";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether `value` is a boolean primitive.
|
|
23
|
+
*
|
|
24
|
+
* @param {unknown} value The value to test
|
|
25
|
+
* @returns {value is boolean} Whether `typeof value` is `"boolean"`
|
|
26
|
+
*/
|
|
27
|
+
export function isBoolean(value) {
|
|
28
|
+
return typeof value === "boolean";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether `value` is callable: a function, an arrow, a class or a method.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} value The value to test
|
|
35
|
+
* @returns {value is Function} Whether `typeof value` is `"function"`
|
|
36
|
+
*/
|
|
37
|
+
export function isFunction(value) {
|
|
38
|
+
return typeof value === "function";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether `value` is an object other than `null`: arrays are objects.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} value The value to test
|
|
45
|
+
* @returns {value is object} Whether `typeof value` is `"object"` and `value` is not `null`
|
|
46
|
+
*/
|
|
47
|
+
export function isNonNullObject(value) {
|
|
48
|
+
return typeof value === "object" && value !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether `value` can carry properties a caller reads: an object or a
|
|
53
|
+
* function, `null` excluded. Every property read through it is `unknown`.
|
|
54
|
+
*
|
|
55
|
+
* @param {unknown} value The value to test
|
|
56
|
+
* @returns {value is Record<string, unknown>} Whether `value` is an object or a function
|
|
57
|
+
*/
|
|
58
|
+
export function isObjectLike(value) {
|
|
59
|
+
return isNonNullObject(value) || isFunction(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether `value` is an object other than `null`, read by its properties:
|
|
64
|
+
* arrays are objects, functions are not.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} value The value to test
|
|
67
|
+
* @returns {value is Record<string, unknown>} Whether `typeof value` is `"object"` and `value` is not `null`
|
|
68
|
+
*/
|
|
69
|
+
export function isRecord(value) {
|
|
70
|
+
return isNonNullObject(value);
|
|
71
|
+
}
|
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
import { isObjectLike, isString } from "./type-guards.mjs";
|
|
2
|
+
|
|
1
3
|
const INTERNAL_ERROR_STATUS = 500;
|
|
2
4
|
const ERROR_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
3
5
|
const VITE_ERROR = Symbol("dms.viteError");
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* A request that carries the error Vite's middlewares failed with.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {import("node:http").IncomingMessage & { [VITE_ERROR]?: unknown }} ViteRequest
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What Vite's middlewares did with a request.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {{ status: "handled" } | { status: "unhandled" } | { status: "failed", error: unknown }} ViteMiddlewareResult
|
|
17
|
+
*/
|
|
18
|
+
|
|
5
19
|
/**
|
|
6
20
|
* In middleware mode, Vite's own error middleware logs an error, sends it to
|
|
7
21
|
* the client overlay, then passes the request on with no error: a module that
|
|
8
22
|
* fails to transform would fall through to page handling and answer 404. This
|
|
9
23
|
* plugin runs just before it, keeps the error on the request and hands it on,
|
|
10
24
|
* so Vite still logs it and shows the overlay.
|
|
25
|
+
*
|
|
26
|
+
* @returns {import("vite").Plugin}
|
|
11
27
|
*/
|
|
12
28
|
export function captureViteErrors() {
|
|
13
29
|
return {
|
|
@@ -15,10 +31,18 @@ export function captureViteErrors() {
|
|
|
15
31
|
apply: "serve",
|
|
16
32
|
configureServer(server) {
|
|
17
33
|
return () =>
|
|
18
|
-
server.middlewares.use(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
server.middlewares.use(
|
|
35
|
+
/**
|
|
36
|
+
* @param {unknown} error
|
|
37
|
+
* @param {ViteRequest} request
|
|
38
|
+
* @param {import("node:http").ServerResponse} _response
|
|
39
|
+
* @param {(error?: unknown) => void} next
|
|
40
|
+
*/
|
|
41
|
+
(error, request, _response, next) => {
|
|
42
|
+
request[VITE_ERROR] = error;
|
|
43
|
+
next(error);
|
|
44
|
+
},
|
|
45
|
+
);
|
|
22
46
|
},
|
|
23
47
|
};
|
|
24
48
|
}
|
|
@@ -27,10 +51,15 @@ export function captureViteErrors() {
|
|
|
27
51
|
* Hands a request to Vite's middlewares. Resolves `handled` once Vite answered
|
|
28
52
|
* it, `unhandled` when Vite passed it on, and `failed` with the error when a
|
|
29
53
|
* Vite middleware failed on it.
|
|
54
|
+
*
|
|
55
|
+
* @param {import("vite").ViteDevServer} devServer
|
|
56
|
+
* @param {ViteRequest} request
|
|
57
|
+
* @param {import("node:http").ServerResponse} response
|
|
58
|
+
* @returns {Promise<ViteMiddlewareResult>}
|
|
30
59
|
*/
|
|
31
60
|
export function runViteMiddlewares(devServer, request, response) {
|
|
32
61
|
return new Promise((resolve) => {
|
|
33
|
-
devServer.middlewares(request, response, (error) => {
|
|
62
|
+
devServer.middlewares(request, response, (/** @type {unknown} */ error) => {
|
|
34
63
|
const failure = error ?? request[VITE_ERROR];
|
|
35
64
|
resolve(
|
|
36
65
|
failure
|
|
@@ -42,9 +71,18 @@ export function runViteMiddlewares(devServer, request, response) {
|
|
|
42
71
|
});
|
|
43
72
|
}
|
|
44
73
|
|
|
45
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Answers 500 with Vite's error message instead of rendering a page.
|
|
76
|
+
*
|
|
77
|
+
* @param {import("node:http").ServerResponse} response
|
|
78
|
+
* @param {unknown} error
|
|
79
|
+
*/
|
|
46
80
|
export function writeViteError(response, error) {
|
|
47
81
|
response.statusCode = INTERNAL_ERROR_STATUS;
|
|
48
82
|
response.setHeader("content-type", ERROR_CONTENT_TYPE);
|
|
49
|
-
response.end(
|
|
83
|
+
response.end(
|
|
84
|
+
isObjectLike(error) && isString(error.message)
|
|
85
|
+
? error.message
|
|
86
|
+
: String(error),
|
|
87
|
+
);
|
|
50
88
|
}
|
|
@@ -11,13 +11,17 @@ import { pipeline } from "node:stream/promises";
|
|
|
11
11
|
*
|
|
12
12
|
* Writes a Web-standard body, a backend answer above all, to a Node response.
|
|
13
13
|
* It lives apart from the proxy and the module routes, which both send one.
|
|
14
|
+
*
|
|
15
|
+
* @param {import("node:stream/web").ReadableStream} body
|
|
16
|
+
* @param {...import("node:stream").Writable} streams
|
|
14
17
|
*/
|
|
15
18
|
export async function pipeWebBody(body, ...streams) {
|
|
16
19
|
const response = streams.at(-1);
|
|
20
|
+
if (!response) throw new TypeError("pipeWebBody needs a response to write");
|
|
17
21
|
if (response.closed || response.destroyed) {
|
|
18
22
|
for (const stream of streams.slice(0, -1)) stream.destroy();
|
|
19
23
|
await body.cancel().catch(() => {});
|
|
20
24
|
return;
|
|
21
25
|
}
|
|
22
|
-
await pipeline(Readable.fromWeb(body), ...streams);
|
|
26
|
+
await pipeline([Readable.fromWeb(body), ...streams]);
|
|
23
27
|
}
|