@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.
Files changed (123) hide show
  1. package/README.md +18 -24
  2. package/dist/commands/build-action.js +3 -2
  3. package/dist/commands/dev-action.js +2 -1
  4. package/dist/commands/prepare-action.js +1 -1
  5. package/dist/commands/start-action.js +3 -24
  6. package/dist/commands/workspace-task.js +2 -1
  7. package/dist/config.js +3 -0
  8. package/dist/derived-outputs.js +3 -3
  9. package/dist/discovery.js +17 -6
  10. package/dist/entry-weight.js +15 -0
  11. package/dist/fs-sync.js +7 -4
  12. package/dist/index.js +2 -1
  13. package/dist/internal/built-workspace.d.ts +4 -0
  14. package/dist/internal/built-workspace.js +43 -0
  15. package/dist/internal/ensure.d.ts +1 -0
  16. package/dist/internal/ensure.js +8 -0
  17. package/dist/internal/locale-catalogs.d.ts +8 -3
  18. package/dist/internal/locale-catalogs.js +16 -6
  19. package/dist/internal/manifest-decoder.d.ts +7 -0
  20. package/dist/internal/manifest-decoder.js +81 -0
  21. package/dist/internal/own-release.d.ts +5 -0
  22. package/dist/internal/own-release.js +20 -0
  23. package/dist/internal/plain-object.d.ts +1 -0
  24. package/dist/internal/plain-object.js +6 -0
  25. package/dist/internal/text-list.d.ts +1 -0
  26. package/dist/internal/text-list.js +10 -0
  27. package/dist/internal/vite-manifest.d.ts +6 -0
  28. package/dist/internal/vite-manifest.js +24 -0
  29. package/dist/layer-watch.js +25 -24
  30. package/dist/layers.d.ts +1 -1
  31. package/dist/layers.js +3 -1
  32. package/dist/locale-files.d.ts +2 -1
  33. package/dist/locale-files.js +4 -0
  34. package/dist/manifest.d.ts +1 -1
  35. package/dist/manifest.js +44 -31
  36. package/dist/materialize.js +6 -2
  37. package/dist/module-set.js +2 -1
  38. package/dist/node-options.d.ts +1 -0
  39. package/dist/node-options.js +8 -0
  40. package/dist/output.d.ts +15 -9
  41. package/dist/output.js +30 -18
  42. package/dist/private-values.d.ts +1 -1
  43. package/dist/private-values.js +5 -3
  44. package/dist/project-modules.js +13 -8
  45. package/dist/renderer-range.js +5 -3
  46. package/dist/server-process.js +1 -1
  47. package/dist/verify-source-build.js +10 -2
  48. package/dist/verify-source-client-bundle.d.ts +1 -0
  49. package/dist/verify-source-client-bundle.js +30 -0
  50. package/dist/verify-source-runner.js +44 -60
  51. package/dist/verify-source-sources.js +49 -11
  52. package/dist/workspace-setup.js +1 -1
  53. package/package.json +6 -4
  54. package/skills/dms-frontend/SKILL.md +1 -1
  55. package/templates/vue/app-runtime.ts +90 -16
  56. package/templates/vue/client-server-guard.mjs +16 -0
  57. package/templates/vue/compress-assets.mjs +9 -1
  58. package/templates/vue/entry-weight.mjs +22 -0
  59. package/templates/vue/frontend-module.ts +1 -0
  60. package/templates/vue/frontend-runtime/module-registration.ts +2 -2
  61. package/templates/vue/frontend-runtime/state.ts +2 -2
  62. package/templates/vue/frontend-runtime/types.ts +8 -2
  63. package/templates/vue/globals.d.ts +5 -0
  64. package/templates/vue/head-order.mjs +7 -0
  65. package/templates/vue/icon-bundle.ts +31 -0
  66. package/templates/vue/icon-scan.mjs +4 -0
  67. package/templates/vue/message-subset.ts +65 -0
  68. package/templates/vue/network-error.ts +4 -0
  69. package/templates/vue/server/auth/backend.mjs +41 -4
  70. package/templates/vue/server/auth/client-ip.mjs +52 -4
  71. package/templates/vue/server/auth/establish-endpoints.mjs +10 -6
  72. package/templates/vue/server/auth/session.mjs +81 -2
  73. package/templates/vue/server/auth-hooks.mjs +15 -5
  74. package/templates/vue/server/backend-proxies.mjs +65 -9
  75. package/templates/vue/server/backend-request.mjs +61 -0
  76. package/templates/vue/server/backend-response.mjs +23 -6
  77. package/templates/vue/server/built-files.mjs +7 -0
  78. package/templates/vue/server/cli-report.mjs +53 -11
  79. package/templates/vue/server/client-manifest.mjs +23 -1
  80. package/templates/vue/server/content.mjs +18 -1
  81. package/templates/vue/server/dev-private-files.mjs +17 -1
  82. package/templates/vue/server/dev-styles.mjs +38 -0
  83. package/templates/vue/server/email.mjs +9 -3
  84. package/templates/vue/server/favicon.mjs +3 -0
  85. package/templates/vue/server/generated-files.mjs +211 -0
  86. package/templates/vue/server/homepage.mjs +21 -9
  87. package/templates/vue/server/html-tag.mjs +4 -0
  88. package/templates/vue/server/icons.mjs +80 -7
  89. package/templates/vue/server/in-place-render.mjs +35 -0
  90. package/templates/vue/server/inertia.mjs +61 -2
  91. package/templates/vue/server/module-event.mjs +98 -17
  92. package/templates/vue/server/module-route-types.mjs +78 -0
  93. package/templates/vue/server/module-routes.mjs +138 -31
  94. package/templates/vue/server/module-server.mjs +98 -27
  95. package/templates/vue/server/module-testing.mjs +14 -5
  96. package/templates/vue/server/page-props.mjs +92 -2
  97. package/templates/vue/server/page-redirects.mjs +37 -0
  98. package/templates/vue/server/path-patterns.mjs +13 -1
  99. package/templates/vue/server/proxy-compression.mjs +22 -0
  100. package/templates/vue/server/proxy-headers.mjs +11 -1
  101. package/templates/vue/server/proxy-redirects.mjs +20 -5
  102. package/templates/vue/server/proxy-request.mjs +57 -0
  103. package/templates/vue/server/proxy-transfers.mjs +33 -5
  104. package/templates/vue/server/public-endpoints.mjs +138 -23
  105. package/templates/vue/server/public-options.mjs +20 -6
  106. package/templates/vue/server/request-failure.mjs +23 -0
  107. package/templates/vue/server/request-routing.mjs +26 -9
  108. package/templates/vue/server/request-target.mjs +9 -3
  109. package/templates/vue/server/route-rules.mjs +39 -15
  110. package/templates/vue/server/ssr-module.mjs +32 -0
  111. package/templates/vue/server/static-files.mjs +24 -1
  112. package/templates/vue/server/type-guards.mjs +71 -0
  113. package/templates/vue/server/vite-errors.mjs +32 -5
  114. package/templates/vue/server/web-body.mjs +3 -0
  115. package/templates/vue/server-module.ts +10 -1
  116. package/templates/vue/server.mjs +132 -148
  117. package/templates/vue/sfc-global-types.mjs +112 -13
  118. package/templates/vue/ssr-renderer.ts +12 -3
  119. package/templates/vue/ui-inertia-page.mjs +16 -1
  120. package/templates/vue/ui-kbd.mjs +2 -1
  121. package/templates/vue/ui-lazy-icons.mjs +44 -0
  122. package/templates/vue/ui-request-state.mjs +2 -1
  123. 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((error) =>
105
- console.error(
106
- "DMS icon collections not loaded; server renders draw bundled icons only",
107
- error,
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
- /** Whether the generated server answers `pathname` with `iconResponse`. */
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
- /** The once props the browser already holds, unexpired, by key. */
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
- /** A page visit: a document or Inertia request, as opposed to an API call. */
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
- /** The request body as a Web stream that errors past `limit` bytes. */
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 (typeof name !== "string" || !COOKIE_NAME.test(name))
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 ${name}`,
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 = (name) => moduleCookieName(name, moduleId, authOwner);
199
+ const named = (/** @type {unknown} */ name) =>
200
+ moduleCookieName(name, moduleId, authOwner);
141
201
  return {
142
- readSealed: (name) => readSealedCookie(request, named(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 = {}) => backend(path, request, init),
186
- backendUrl: (path) => backendUrl(path),
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: (template, props, options = {}) =>
194
- renderEmailTemplate(template, props, options),
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
- (typeof value === "object" &&
210
- value !== null &&
211
- typeof value.status === "number" &&
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)) return 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) {