@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.
Files changed (140) hide show
  1. package/README.md +46 -29
  2. package/dist/commands/build-action.d.ts +3 -0
  3. package/dist/commands/build-action.js +77 -8
  4. package/dist/commands/build.js +7 -0
  5. package/dist/commands/clean-action.js +1 -1
  6. package/dist/commands/clean.js +1 -1
  7. package/dist/commands/dev-action.js +4 -3
  8. package/dist/commands/dev.js +1 -1
  9. package/dist/commands/prepare-action.js +1 -1
  10. package/dist/commands/start-action.js +3 -24
  11. package/dist/commands/workspace-task.js +2 -1
  12. package/dist/config.js +1 -0
  13. package/dist/derived-outputs.d.ts +1 -0
  14. package/dist/derived-outputs.js +6 -4
  15. package/dist/discovery.js +17 -6
  16. package/dist/entry-weight.js +15 -0
  17. package/dist/fs-sync.js +7 -4
  18. package/dist/help.js +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/internal/built-workspace.d.ts +4 -0
  21. package/dist/internal/built-workspace.js +43 -0
  22. package/dist/internal/ensure.d.ts +1 -0
  23. package/dist/internal/ensure.js +8 -0
  24. package/dist/internal/locale-catalogs.d.ts +8 -3
  25. package/dist/internal/locale-catalogs.js +16 -6
  26. package/dist/internal/manifest-decoder.d.ts +7 -0
  27. package/dist/internal/manifest-decoder.js +81 -0
  28. package/dist/internal/own-release.d.ts +5 -0
  29. package/dist/internal/own-release.js +20 -0
  30. package/dist/internal/plain-object.d.ts +1 -0
  31. package/dist/internal/plain-object.js +6 -0
  32. package/dist/internal/prepared-workspace.d.ts +12 -0
  33. package/dist/internal/prepared-workspace.js +74 -0
  34. package/dist/internal/text-list.d.ts +1 -0
  35. package/dist/internal/text-list.js +10 -0
  36. package/dist/internal/vite-manifest.d.ts +6 -0
  37. package/dist/internal/vite-manifest.js +24 -0
  38. package/dist/layer-watch.js +25 -24
  39. package/dist/layers.d.ts +1 -1
  40. package/dist/layers.js +3 -1
  41. package/dist/locale-files.d.ts +6 -3
  42. package/dist/locale-files.js +15 -9
  43. package/dist/manifest.d.ts +1 -1
  44. package/dist/manifest.js +46 -32
  45. package/dist/materialize.js +6 -2
  46. package/dist/module-set.js +2 -1
  47. package/dist/node-options.d.ts +1 -0
  48. package/dist/node-options.js +8 -0
  49. package/dist/output.d.ts +15 -9
  50. package/dist/output.js +30 -18
  51. package/dist/ports.d.ts +3 -1
  52. package/dist/ports.js +1 -1
  53. package/dist/private-values.d.ts +1 -1
  54. package/dist/private-values.js +5 -3
  55. package/dist/project-modules.d.ts +5 -1
  56. package/dist/project-modules.js +23 -15
  57. package/dist/renderer-range.js +5 -3
  58. package/dist/server-process.js +1 -1
  59. package/dist/verify-source-build.js +10 -2
  60. package/dist/verify-source-client-bundle.d.ts +1 -0
  61. package/dist/verify-source-client-bundle.js +30 -0
  62. package/dist/verify-source-runner.js +36 -94
  63. package/dist/verify-source-sources.js +49 -11
  64. package/dist/verify-source-workspace.d.ts +2 -0
  65. package/dist/verify-source-workspace.js +111 -0
  66. package/dist/workspace-setup.d.ts +1 -0
  67. package/dist/workspace-setup.js +4 -3
  68. package/package.json +11 -7
  69. package/skills/dms-frontend/SKILL.md +1 -1
  70. package/templates/vue/app-runtime.ts +2 -9
  71. package/templates/vue/backend-boundary.mjs +132 -0
  72. package/templates/vue/client-chunking.mjs +2 -0
  73. package/templates/vue/client-server-guard.mjs +16 -0
  74. package/templates/vue/compress-assets.mjs +9 -1
  75. package/templates/vue/entry-weight.mjs +29 -3
  76. package/templates/vue/frontend-build.ts +4 -3
  77. package/templates/vue/frontend-module.ts +1 -0
  78. package/templates/vue/frontend-runtime/module-registration.ts +2 -2
  79. package/templates/vue/frontend-runtime/state.ts +2 -2
  80. package/templates/vue/frontend-runtime/types.ts +8 -2
  81. package/templates/vue/head-order.mjs +10 -2
  82. package/templates/vue/icon-api.mjs +2 -0
  83. package/templates/vue/icon-scan.mjs +4 -0
  84. package/templates/vue/server/auth/backend.mjs +42 -5
  85. package/templates/vue/server/auth/client-ip.mjs +52 -4
  86. package/templates/vue/server/auth/establish-endpoints.mjs +11 -7
  87. package/templates/vue/server/auth/session.mjs +86 -4
  88. package/templates/vue/server/auth-declarations.mjs +46 -0
  89. package/templates/vue/server/auth-hooks.mjs +55 -7
  90. package/templates/vue/server/backend-proxies.mjs +78 -18
  91. package/templates/vue/server/backend-request.mjs +61 -0
  92. package/templates/vue/server/backend-response.mjs +23 -6
  93. package/templates/vue/server/built-files.mjs +7 -0
  94. package/templates/vue/server/cli-report.mjs +75 -12
  95. package/templates/vue/server/client-error.mjs +5 -0
  96. package/templates/vue/server/client-manifest.mjs +23 -1
  97. package/templates/vue/server/content.mjs +40 -10
  98. package/templates/vue/server/dev-private-files.mjs +17 -1
  99. package/templates/vue/server/dev-styles.mjs +38 -0
  100. package/templates/vue/server/email-bundle-watch.mjs +9 -3
  101. package/templates/vue/server/email.mjs +14 -6
  102. package/templates/vue/server/favicon.mjs +3 -0
  103. package/templates/vue/server/generated-files.mjs +221 -0
  104. package/templates/vue/server/homepage.mjs +21 -9
  105. package/templates/vue/server/html-tag.mjs +4 -0
  106. package/templates/vue/server/icons.mjs +93 -16
  107. package/templates/vue/server/in-place-render.mjs +35 -0
  108. package/templates/vue/server/inertia.mjs +76 -3
  109. package/templates/vue/server/module-event.mjs +118 -25
  110. package/templates/vue/server/module-route-types.mjs +78 -0
  111. package/templates/vue/server/module-routes.mjs +151 -96
  112. package/templates/vue/server/module-server.mjs +121 -40
  113. package/templates/vue/server/module-testing.mjs +36 -12
  114. package/templates/vue/server/page-props.mjs +94 -3
  115. package/templates/vue/server/page-redirects.mjs +37 -0
  116. package/templates/vue/server/path-patterns.mjs +35 -12
  117. package/templates/vue/server/proxy-compression.mjs +22 -0
  118. package/templates/vue/server/proxy-headers.mjs +12 -2
  119. package/templates/vue/server/proxy-redirects.mjs +21 -5
  120. package/templates/vue/server/proxy-request.mjs +58 -0
  121. package/templates/vue/server/proxy-transfers.mjs +44 -10
  122. package/templates/vue/server/public-endpoints.mjs +139 -28
  123. package/templates/vue/server/public-options.mjs +20 -6
  124. package/templates/vue/server/request-failure.mjs +24 -1
  125. package/templates/vue/server/request-routing.mjs +32 -10
  126. package/templates/vue/server/request-target.mjs +30 -11
  127. package/templates/vue/server/route-rules.mjs +48 -18
  128. package/templates/vue/server/ssr-module.mjs +32 -0
  129. package/templates/vue/server/static-files.mjs +35 -7
  130. package/templates/vue/server/type-guards.mjs +71 -0
  131. package/templates/vue/server/vite-errors.mjs +45 -7
  132. package/templates/vue/server/web-body.mjs +5 -1
  133. package/templates/vue/server-module.ts +12 -2
  134. package/templates/vue/server.mjs +162 -157
  135. package/templates/vue/sfc-global-types.mjs +112 -13
  136. package/templates/vue/ssr-renderer.ts +2 -2
  137. package/templates/vue/ui-inertia-page.mjs +16 -1
  138. package/templates/vue/ui-kbd.mjs +2 -1
  139. package/templates/vue/ui-request-state.mjs +2 -1
  140. 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?.code === "ERR_STREAM_PREMATURE_CLOSE" ||
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 (isRendererFilePath(pathname) || hasRendererFile(pathname))
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
- /** The `x-dms-route-owner` value of an owner, for the development server. */
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 { writeBadRequest } from "./request-failure.mjs";
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 (typeof target !== "string" || !target.startsWith("/")) return false;
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
- * Answers 400 to a request whose target is not origin-form, before any route
41
- * reads it. Returns whether it answered.
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 refuseForeignTarget(request, response) {
50
- if (isOriginForm(request.url)) return false;
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
- /** Whether a header value holds a control character other than a tab: a CR
5
- * or an LF would split the answer. */
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 [...value].some((character) => {
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 (typeof headers !== "object" || headers === null || Array.isArray(headers))
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
- typeof value !== "string" ||
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
- typeof cache !== "object" ||
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 "the pattern is not /x, /x/* or /x/**";
69
- if (typeof rule !== "object" || rule === null) return "it is not an object";
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?.publicAccess === true
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
- /** The precompressed variant of `file` the client accepts, if the build wrote one. */
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 accepted = request.headers["accept-encoding"] ?? "";
54
- if (accepted.includes("br") && isFile(`${file}.br`))
55
- return { file: `${file}.br`, encoding: "br" };
56
- if (accepted.includes("gzip") && isFile(`${file}.gz`))
57
- return { file: `${file}.gz`, encoding: "gzip" };
58
- return { file, encoding: undefined };
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((error, request, _response, next) => {
19
- request[VITE_ERROR] = error;
20
- next(error);
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
- /** Answers 500 with Vite's error message instead of rendering a page. */
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(error?.message ?? String(error));
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
  }