@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
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { allowedEstablishEndpoints } from "./auth/establish-endpoints.mjs";
4
4
  import { serverAuth } from "./auth-hooks.mjs";
5
5
  import { answerModuleRoute } from "./module-event.mjs";
6
+ import { isNonNullObject, isRecord, isString } from "./type-guards.mjs";
6
7
  import {
7
8
  matchModuleRoute,
8
9
  mayHaveModuleRoute,
@@ -16,7 +17,30 @@ import {
16
17
  } from "./public-endpoints.mjs";
17
18
  import { reachesBackend } from "./request-routing.mjs";
18
19
 
20
+ import { frontendRegistryOf, serverOptionsOf } from "./generated-files.mjs";
19
21
  import { publicOptionsOf } from "./public-options.mjs";
22
+ /** @typedef {import("./module-route-types.mjs").ServerModuleDeclaration} ServerModuleDeclaration */
23
+ /**
24
+ * What a handler of a module reads besides its request.
25
+ *
26
+ * @typedef {object} ModuleContext
27
+ * @property {{ id: string, options: unknown, privateOptions: unknown }} module
28
+ * @property {{ authEstablishEndpoints: readonly string[] }} app
29
+ * @property {string | undefined} authOwner The module that owns sign-in
30
+ */
31
+ /**
32
+ * A deployment's data, which the server hands to a set of server modules.
33
+ *
34
+ * @typedef {object} Deployment
35
+ * @property {Record<string, unknown>} [publicOptions] Each module's public options by id
36
+ * @property {Record<string, unknown>} [privateOptions] Each module's private options by id
37
+ * @property {() => readonly string[]} [authEstablishEndpoints]
38
+ * @property {import("./public-endpoints.mjs").PublicEndpoint[]} [publicEndpoints]
39
+ * @property {import("./backend-proxies.mjs").ManifestProxy[]} [backendProxies]
40
+ * @property {(message: string) => void} warn
41
+ */
42
+ /** @typedef {ReturnType<typeof createServerModules>} ServerModules */
43
+
20
44
  const WORKSPACE_ROOT = new URL("../", import.meta.url);
21
45
  const SOURCE_LOADER = "/server-modules.generated.ts";
22
46
  const BUILT_LOADER = new URL(
@@ -32,11 +56,20 @@ const SERVER_OPTIONS_FILE = new URL(
32
56
  WORKSPACE_ROOT,
33
57
  );
34
58
 
59
+ /** @type {Promise<ServerModuleDeclaration[]> | undefined} */
35
60
  let builtDeclarations;
61
+ /** @type {ServerModuleDeclaration[]} */
36
62
  let lastDeclarations = [];
63
+ /** @type {string | undefined} */
37
64
  let lastLoadError;
65
+ /** @type {WeakMap<ServerModuleDeclaration[], ServerModules>} */
38
66
  const resolved = new WeakMap();
39
67
 
68
+ /**
69
+ * @param {URL} url
70
+ * @param {unknown} fallback
71
+ * @returns {unknown} The file's JSON, the fallback when it cannot be read
72
+ */
40
73
  function readJsonFile(url, fallback) {
41
74
  try {
42
75
  return JSON.parse(readFileSync(url, "utf8"));
@@ -47,31 +80,58 @@ function readJsonFile(url, fallback) {
47
80
 
48
81
  const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
49
82
 
50
- /** The object holding `path`'s last key, when every key up to it is own. */
51
- function holderOf(target, path) {
83
+ /**
84
+ * The object holding `path`'s last key, and that key, when every key up to it
85
+ * is own.
86
+ *
87
+ * @param {Record<string, unknown>} target
88
+ * @param {string[]} path
89
+ * @returns {{ holder: Record<string, unknown>, key: string } | undefined}
90
+ */
91
+ function slotOf(target, path) {
92
+ const key = path.at(-1);
93
+ if (key === undefined || UNSAFE_KEYS.has(key)) return undefined;
52
94
  let node = target;
53
- for (const key of path.slice(0, -1)) {
54
- if (UNSAFE_KEYS.has(key) || !Object.hasOwn(node, key)) return undefined;
55
- node = node[key];
56
- if (typeof node !== "object" || node === null) return undefined;
95
+ for (const step of path.slice(0, -1)) {
96
+ if (UNSAFE_KEYS.has(step) || !Object.hasOwn(node, step)) return undefined;
97
+ const next = node[step];
98
+ if (!isRecord(next)) return undefined;
99
+ node = next;
57
100
  }
58
- return UNSAFE_KEYS.has(path.at(-1)) ? undefined : node;
101
+ return { holder: node, key };
59
102
  }
60
103
 
61
- /** Sets `value` at `path` when the object holding it exists but lacks it. */
104
+ /**
105
+ * Sets `value` at `path` when the object holding it exists but lacks it.
106
+ *
107
+ * @param {Record<string, unknown>} target
108
+ * @param {string[]} path
109
+ * @param {string} value
110
+ */
62
111
  function fillPath(target, path, value) {
63
- const holder = holderOf(target, path);
64
- const key = path.at(-1);
65
- if (holder && (holder[key] === undefined || holder[key] === ""))
66
- holder[key] = value;
112
+ const slot = slotOf(target, path);
113
+ if (
114
+ slot &&
115
+ (slot.holder[slot.key] === undefined || slot.holder[slot.key] === "")
116
+ )
117
+ slot.holder[slot.key] = value;
67
118
  }
68
119
 
69
- /** Sets `value` at `path` only where the build left an empty string. */
120
+ /**
121
+ * Sets `value` at `path` only where the build left an empty string.
122
+ *
123
+ * @param {Record<string, unknown>} target
124
+ * @param {string[]} path
125
+ * @param {string} value
126
+ */
70
127
  function fillEmptied(target, path, value) {
71
- const holder = holderOf(target, path);
72
- const key = path.at(-1);
73
- if (holder && Object.hasOwn(holder, key) && holder[key] === "")
74
- holder[key] = value;
128
+ const slot = slotOf(target, path);
129
+ if (
130
+ slot &&
131
+ Object.hasOwn(slot.holder, slot.key) &&
132
+ slot.holder[slot.key] === ""
133
+ )
134
+ slot.holder[slot.key] = value;
75
135
  }
76
136
 
77
137
  /**
@@ -79,10 +139,10 @@ function fillEmptied(target, path, value) {
79
139
  * the environment variable of a secret (`DMS_HTML_RENDER_SECRET`,
80
140
  * `DMS_OAUTH_RELAY_SECRET`) only fills in a section that carries none.
81
141
  *
82
- * @param modules The private options by module id, as the manifest published
142
+ * @param {Record<string, Record<string, unknown>>} modules The private options by module id, as the manifest published
83
143
  * them
84
- * @param environment The variables by the path of the secret they stand for
85
- * @param env The process environment
144
+ * @param {Record<string, string[]>} environment The variables by the path of the secret they stand for
145
+ * @param {NodeJS.ProcessEnv} env The process environment
86
146
  */
87
147
  export function privateOptionsWithFallbacks(modules, environment, env) {
88
148
  for (const options of Object.values(modules))
@@ -91,10 +151,16 @@ export function privateOptionsWithFallbacks(modules, environment, env) {
91
151
  return modules;
92
152
  }
93
153
 
94
- /** Each string leaf of `value`, array items included, with its path. */
154
+ /**
155
+ * Each string leaf of `value`, array items included, with its path.
156
+ *
157
+ * @param {unknown} value
158
+ * @param {string[]} [path]
159
+ * @returns {Generator<[string[], string]>}
160
+ */
95
161
  function* stringLeaves(value, path = []) {
96
- if (typeof value === "string") yield [path, value];
97
- else if (typeof value === "object" && value !== null)
162
+ if (isString(value)) yield [path, value];
163
+ else if (isNonNullObject(value))
98
164
  for (const [key, child] of Object.entries(value))
99
165
  yield* stringLeaves(child, [...path, key]);
100
166
  }
@@ -107,19 +173,20 @@ function* stringLeaves(value, path = []) {
107
173
  * a value the build emptied is filled, array items included: a value the
108
174
  * manifest carries wins, and no option or section is added or replaced.
109
175
  *
110
- * @param modules The private options by module id
111
- * @param json The variable's value; nothing is filled when unset
112
- * @param warn Reports a value that is not a JSON object, never its content
176
+ * @param {Record<string, Record<string, unknown>>} modules The private options by module id
177
+ * @param {string | undefined} json The variable's value; nothing is filled when unset
178
+ * @param {(message: string) => void} warn Reports a value that is not a JSON object, never its content
113
179
  */
114
180
  export function privateOptionsFromJson(modules, json, warn) {
115
181
  if (!json) return modules;
182
+ /** @type {unknown} */
116
183
  let given;
117
184
  try {
118
185
  given = JSON.parse(json);
119
186
  } catch {
120
187
  given = undefined;
121
188
  }
122
- if (typeof given !== "object" || given === null || Array.isArray(given)) {
189
+ if (!isNonNullObject(given) || Array.isArray(given)) {
123
190
  warn("DMS_PRIVATE_OPTIONS is not a JSON object of options by module id");
124
191
  return modules;
125
192
  }
@@ -131,10 +198,10 @@ export function privateOptionsFromJson(modules, json, warn) {
131
198
  return modules;
132
199
  }
133
200
 
201
+ /** @param {NodeJS.ProcessEnv} [env] */
134
202
  function readPrivateOptions(env = process.env) {
135
- const { modules = {}, environment = {} } = readJsonFile(
136
- SERVER_OPTIONS_FILE,
137
- {},
203
+ const { modules, environment } = serverOptionsOf(
204
+ readJsonFile(SERVER_OPTIONS_FILE, {}),
138
205
  );
139
206
  return privateOptionsFromJson(
140
207
  privateOptionsWithFallbacks(modules, environment, env),
@@ -144,14 +211,22 @@ function readPrivateOptions(env = process.env) {
144
211
  }
145
212
 
146
213
  function readPublicOptions() {
147
- return publicOptionsOf(readJsonFile(REGISTRY_FILE, { modules: [] }));
214
+ return publicOptionsOf(
215
+ frontendRegistryOf(readJsonFile(REGISTRY_FILE, { modules: [] })),
216
+ );
148
217
  }
149
218
 
219
+ /**
220
+ * @param {import("vite").ViteDevServer | undefined} devServer
221
+ * @returns {Promise<ServerModuleDeclaration[]>}
222
+ */
150
223
  async function loadDeclarations(devServer) {
151
224
  if (devServer)
225
+ // oxlint-disable-next-line typescript/no-unsafe-return -- `ssrLoadModule` types every module `Record<string, any>`; the loader is the generated `server-modules.generated.ts`, which exports `serverModules`.
152
226
  return (await devServer.ssrLoadModule(SOURCE_LOADER)).serverModules;
153
227
  if (!existsSync(fileURLToPath(BUILT_LOADER))) return [];
154
228
  builtDeclarations ??= import(BUILT_LOADER.href).then(
229
+ // oxlint-disable-next-line typescript/no-unsafe-return -- the build of that same generated module is imported by a URL computed at run time, so it is untyped.
155
230
  (loaded) => loaded.serverModules,
156
231
  );
157
232
  return builtDeclarations;
@@ -161,6 +236,8 @@ async function loadDeclarations(devServer) {
161
236
  * The declarations to serve. A module entry that fails to load in development
162
237
  * (a syntax error being typed) is reported once, and the last routes that
163
238
  * loaded keep answering meanwhile.
239
+ *
240
+ * @param {import("vite").ViteDevServer | undefined} devServer
164
241
  */
165
242
  async function currentDeclarations(devServer) {
166
243
  try {
@@ -181,8 +258,8 @@ async function currentDeclarations(devServer) {
181
258
  *
182
259
  * `authOwner` is the module whose sealed cookies are the `dms_*` ones.
183
260
  *
184
- * @param declarations `{ id, priority, setup }` per module with a dms.server.ts
185
- * @param deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
261
+ * @param {readonly ServerModuleDeclaration[]} declarations `{ id, priority, setup }` per module with a dms.server.ts
262
+ * @param {Deployment} deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
186
263
  * publicEndpoints, backendProxies, warn }`: each module's options by id,
187
264
  * the session-opening endpoints as a function (the environment can change
188
265
  * them), the public endpoints, the backend proxies the manifest declares
@@ -198,7 +275,7 @@ export function createServerModules(declarations, deployment) {
198
275
  deployment.publicEndpoints ?? [],
199
276
  deployment.backendProxies ?? [],
200
277
  );
201
- const context = (id) => ({
278
+ const context = (/** @type {string} */ id) => ({
202
279
  module: {
203
280
  id,
204
281
  options: deployment.publicOptions?.[id],
@@ -216,7 +293,11 @@ export function createServerModules(declarations, deployment) {
216
293
  };
217
294
  }
218
295
 
219
- /** Resolved once per set of declarations: in development, once per edit. */
296
+ /**
297
+ * Resolved once per set of declarations: in development, once per edit.
298
+ *
299
+ * @param {ServerModuleDeclaration[]} declarations
300
+ */
220
301
  function serverModules(declarations) {
221
302
  let modules = resolved.get(declarations);
222
303
  if (!modules) {
@@ -245,7 +326,7 @@ function serverModules(declarations) {
245
326
  * next request; in production from the bundle `ajs dms build` makes
246
327
  * (`dist/server-modules`). A workspace built before the feature has none.
247
328
  *
248
- * @param devServer Vite in development, `undefined` in production
329
+ * @param {import("vite").ViteDevServer | undefined} devServer Vite in development, `undefined` in production
249
330
  */
250
331
  export async function loadServerModules(devServer) {
251
332
  return serverModules(await currentDeclarations(devServer));
@@ -256,10 +337,10 @@ export async function loadServerModules(devServer) {
256
337
  * module route serves is the module's for every method: another method is
257
338
  * answered `405`.
258
339
  *
259
- * @param request The Node request
260
- * @param response The Node response
261
- * @param pathname The request's normalized path
262
- * @param modules What `loadServerModules` or `createServerModules` returned
340
+ * @param {import("./request-target.mjs").ServerRequest} request The Node request
341
+ * @param {import("node:http").ServerResponse} response The Node response
342
+ * @param {string} pathname The request's normalized path
343
+ * @param {ServerModules} modules What `loadServerModules` or `createServerModules` returned
263
344
  * @returns Whether a module route answered
264
345
  */
265
346
  export async function handleModuleRoute(request, response, pathname, modules) {
@@ -2,10 +2,18 @@ import { createServer } from "node:http";
2
2
  import { RequestBodyError, UpstreamError } from "./auth/backend.mjs";
3
3
  import { UNEXPECTED_ERROR_BODY } from "./backend-response.mjs";
4
4
  import { createServerModules, handleModuleRoute } from "./module-server.mjs";
5
+ import { failureStatus, writeBadRequest } from "./request-failure.mjs";
6
+ import { isServerRequest } from "./request-target.mjs";
7
+ import { isString } from "./type-guards.mjs";
5
8
 
6
- /** What the frontend server answers an API request whose handler failed. */
9
+ /**
10
+ * What the frontend server answers an API request whose handler failed.
11
+ *
12
+ * @param {unknown} error
13
+ * @param {import("node:http").ServerResponse} response
14
+ */
7
15
  function writeFailure(error, response) {
8
- const status = Number.isInteger(error?.status) ? error.status : 502;
16
+ const status = failureStatus(error);
9
17
  const body =
10
18
  error instanceof UpstreamError || error instanceof RequestBodyError
11
19
  ? { message: error.message }
@@ -26,9 +34,9 @@ function writeFailure(error, response) {
26
34
  * `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the environment, as in
27
35
  * production.
28
36
  *
29
- * @param declarations `{ id, priority?, setup }` per module, `setup` being
37
+ * @param {readonly import("./module-server.mjs").ServerModuleDeclaration[]} declarations `{ id, priority?, setup }` per module, `setup` being
30
38
  * the default export of its `dms.server.ts`
31
- * @param deployment `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
39
+ * @param {Omit<import("./module-server.mjs").Deployment, "warn" | "authEstablishEndpoints"> & { authEstablishEndpoints?: readonly string[] }} [deployment] `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
32
40
  * publicEndpoints?, backendProxies? }`, options by module id, and the
33
41
  * manifest's backend proxies (`{ module, path, auth? }`), which decide
34
42
  * the backend redirects relayed as frontend paths. The private options are
@@ -37,6 +45,7 @@ function writeFailure(error, response) {
37
45
  * @returns `{ url, modules, warnings, close() }`
38
46
  */
39
47
  export async function serveModuleRoutes(declarations, deployment = {}) {
48
+ /** @type {string[]} */
40
49
  const warnings = [];
41
50
  const endpoints = deployment.authEstablishEndpoints ?? [];
42
51
  const modules = createServerModules(declarations, {
@@ -45,23 +54,38 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
45
54
  warn: (message) => warnings.push(message),
46
55
  });
47
56
  const server = createServer((request, response) => {
57
+ if (!isServerRequest(request)) {
58
+ writeBadRequest(response);
59
+ return;
60
+ }
48
61
  const { pathname } = new URL(request.url, "http://frontend.local");
49
62
  handleModuleRoute(request, response, pathname, modules)
50
63
  .then((answered) => {
51
64
  if (!answered) response.writeHead(404).end();
52
65
  })
53
- .catch((error) => writeFailure(error, response));
66
+ .catch(
67
+ /** @param {unknown} error */ (error) => writeFailure(error, response),
68
+ );
54
69
  });
55
- await new Promise((settle) => server.listen(0, "127.0.0.1", settle));
56
- const { port } = server.address();
70
+ /** @type {Promise<void>} */
71
+ const listening = new Promise((settle) =>
72
+ server.listen(0, "127.0.0.1", settle),
73
+ );
74
+ await listening;
75
+ const address = server.address();
76
+ if (address === null || isString(address))
77
+ throw new TypeError("The module test server is not listening on a port");
78
+ const { port } = address;
79
+ /** @returns {Promise<void>} */
80
+ const close = () =>
81
+ new Promise((settle) => {
82
+ server.closeAllConnections();
83
+ server.close(() => settle());
84
+ });
57
85
  return {
58
86
  url: `http://127.0.0.1:${port}`,
59
87
  modules,
60
88
  warnings,
61
- close: () =>
62
- new Promise((settle) => {
63
- server.closeAllConnections();
64
- server.close(() => settle());
65
- }),
89
+ close,
66
90
  };
67
91
  }
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readSession } from "./auth/session.mjs";
3
+ import { GENERIC_ERROR_MESSAGE, UpstreamError } from "./auth/backend.mjs";
3
4
  import { BackendResponseError } from "./backend-response.mjs";
4
5
  import { heldOnceProps, requestedProps } from "./inertia.mjs";
6
+ import { isObjectLike, isRecord } from "./type-guards.mjs";
5
7
 
6
8
  export const SHARED_PROP = "shared";
7
9
  /**
@@ -15,10 +17,59 @@ export const SHARED_PROP_TTL_MS = 5 * 60 * 1000;
15
17
  const SITE_LAYOUT_PATH = "/dms/sitelayout";
16
18
  const NOT_FOUND = 404;
17
19
 
20
+ /**
21
+ * The props of a page, each only when the request asks for it.
22
+ *
23
+ * @typedef {{
24
+ * path?: string,
25
+ * user?: unknown,
26
+ * session?: unknown,
27
+ * page?: unknown,
28
+ * shared?: unknown
29
+ * }} PageProps
30
+ */
31
+
32
+ /**
33
+ * What a page request reads of the backend: its JSON for a path (any value;
34
+ * `loadPage` refuses a page answer that is not an object), and whether the
35
+ * frontend itself serves a path.
36
+ *
37
+ * @typedef {{
38
+ * fetchJson: (path: string) => Promise<unknown>,
39
+ * isFrontendPage: (pathname: string) => Promise<boolean>
40
+ * }} PageBackend
41
+ */
42
+
43
+ /**
44
+ * What a page request reads of the auth hooks: where sign-in is, and what a
45
+ * session shows a page.
46
+ *
47
+ * @typedef {{
48
+ * signInPath: string,
49
+ * view: (session: import("./auth/session.mjs").Session | undefined) => { user?: unknown, session?: unknown }
50
+ * }} PageAuth
51
+ */
52
+
53
+ /**
54
+ * The route the backend described for the page of `props`, as the backend
55
+ * sent it: any JSON value, `undefined` when the page has none.
56
+ *
57
+ * @param {PageProps} props
58
+ * @returns {unknown}
59
+ */
60
+ export function pageRoute(props) {
61
+ return isObjectLike(props.page) ? props.page.route : undefined;
62
+ }
63
+
18
64
  /**
19
65
  * The once-prop key of the site layout: one per account and workspace. A
20
66
  * browser holding the layout of another account or workspace, after a sign-in,
21
67
  * a sign-out or a switch, holds another key, so it is sent the right one.
68
+ *
69
+ * @param {{ accountId?: string, activeTenantId?: string } | undefined} session
70
+ * The sealed session, of which only the `accountId` and `activeTenantId` are
71
+ * read, as empty when it holds none
72
+ * @returns {string}
22
73
  */
23
74
  export function sharedOnceKey(session) {
24
75
  const identity = `${session?.accountId ?? ""}\n${session?.activeTenantId ?? ""}`;
@@ -30,6 +81,10 @@ export function sharedOnceKey(session) {
30
81
  * Whether the browser holds this session's layout and no other: a browser
31
82
  * that holds another session's key too (a switch back and forth without a
32
83
  * full visit) may show that one under `shared`, so it is sent this one.
84
+ *
85
+ * @param {Set<string>} held
86
+ * @param {string} sharedKey
87
+ * @returns {boolean}
33
88
  */
34
89
  function holdsOnlyThisLayout(held, sharedKey) {
35
90
  if (!held.has(sharedKey)) return false;
@@ -50,19 +105,29 @@ const PAGE_REQUEST_PARAMETERS = new Set(["path", SHARED_PROP]);
50
105
  * on), so the layout is resolved for the query the browser shows and the
51
106
  * browser does not ask it again. One value per name, the last, as the
52
107
  * browser's own page layout request sends it.
108
+ *
109
+ * @param {URL} url
110
+ * @param {boolean} withShared
111
+ * @returns {string}
53
112
  */
54
113
  function pagePath(url, withShared) {
55
114
  const query = new URLSearchParams({ path: url.pathname });
115
+ /** @type {Map<string, string>} */
56
116
  const pageQuery = new Map();
57
117
  for (const [name, value] of url.searchParams) {
58
118
  if (!PAGE_REQUEST_PARAMETERS.has(name)) pageQuery.set(name, value);
59
119
  }
60
120
  for (const [name, value] of pageQuery) query.set(name, value);
61
121
  if (!withShared) query.set(SHARED_PROP, "false");
62
- return `/dms/page?${query}`;
122
+ return `/dms/page?${query.toString()}`;
63
123
  }
64
124
 
65
- /** A page only the frontend has: a custom page, with no backend data. */
125
+ /**
126
+ * A page only the frontend has: a custom page, with no backend data.
127
+ *
128
+ * @param {string} pathname
129
+ * @param {string} signInPath
130
+ */
66
131
  function frontendOnlyPage(pathname, signInPath) {
67
132
  return {
68
133
  componentName: pathname,
@@ -76,6 +141,13 @@ function frontendOnlyPage(pathname, signInPath) {
76
141
  };
77
142
  }
78
143
 
144
+ /**
145
+ * @param {URL} url
146
+ * @param {boolean} withShared
147
+ * @param {PageBackend} backend
148
+ * @param {string} signInPath
149
+ * @returns {Promise<{ page: PageProps["page"], shared: unknown }>}
150
+ */
79
151
  async function loadPage(
80
152
  url,
81
153
  withShared,
@@ -84,7 +156,10 @@ async function loadPage(
84
156
  ) {
85
157
  const { pathname } = url;
86
158
  try {
87
- const { shared, ...page } = await fetchJson(pagePath(url, withShared));
159
+ const answer = await fetchJson(pagePath(url, withShared));
160
+ if (!isRecord(answer) || Array.isArray(answer))
161
+ throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
162
+ const { shared, ...page } = answer;
88
163
  return { page, shared };
89
164
  } catch (error) {
90
165
  if (!(error instanceof BackendResponseError) || error.status !== NOT_FOUND)
@@ -101,6 +176,12 @@ async function loadPage(
101
176
  * The props of the same page at another query of its path (`url`): the page
102
177
  * is read again, since its layout reads the query, and the rest is kept,
103
178
  * the site layout included.
179
+ *
180
+ * @param {PageProps} props
181
+ * @param {URL} url
182
+ * @param {PageBackend} backend
183
+ * @param {PageAuth} auth
184
+ * @returns {Promise<PageProps>}
104
185
  */
105
186
  export async function propsForQuery(props, url, backend, auth) {
106
187
  const { page } = await loadPage(url, false, backend, auth.signInPath);
@@ -125,6 +206,14 @@ export async function propsForQuery(props, url, backend, auth) {
125
206
  * The shared layout is declared only when the browser ends up with this
126
207
  * session's layout, sent now or already held: declaring it otherwise would let
127
208
  * a layout of another session pass for this one.
209
+ *
210
+ * @param {import("./request-target.mjs").ServerRequest} request
211
+ * @param {PageBackend} backend
212
+ * @param {PageAuth} auth
213
+ * @returns {Promise<{
214
+ * props: PageProps,
215
+ * onceProps: import("./inertia.mjs").OnceProps | undefined
216
+ * }>}
128
217
  */
129
218
  export async function loadPageProps(request, backend, auth) {
130
219
  const url = new URL(request.url, "http://frontend.local");
@@ -135,10 +224,12 @@ export async function loadPageProps(request, backend, auth) {
135
224
  const sharedKey = sharedOnceKey(sealed);
136
225
  const holdsShared = holdsOnlyThisLayout(heldOnceProps(request), sharedKey);
137
226
  const withShared = (partial && wants(SHARED_PROP)) || !holdsShared;
227
+ /** @type {PageProps} */
138
228
  const props = {};
139
229
  if (wants("path")) props.path = pathname;
140
230
  if (wants("user") && (user || partial)) props.user = user ?? null;
141
231
  if (wants("session") && (session || partial)) props.session = session ?? null;
232
+ /** @type {unknown} */
142
233
  let shared;
143
234
  if (wants("page")) {
144
235
  const loaded = await loadPage(url, withShared, backend, auth.signInPath);
@@ -0,0 +1,37 @@
1
+ import { readSession } from "./auth/session.mjs";
2
+ import { HOMEPAGE } from "./homepage.mjs";
3
+ import { redirectFrontendVisit } from "./inertia.mjs";
4
+ import { pageRoute } from "./page-props.mjs";
5
+ import { isObjectLike } from "./type-guards.mjs";
6
+
7
+ /**
8
+ * @param {import("node:http").IncomingMessage} request
9
+ * @param {import("node:http").ServerResponse} response
10
+ * @param {string} pathname
11
+ * @returns {boolean}
12
+ */
13
+ export function redirectToHomepage(request, response, pathname) {
14
+ if (pathname !== "/" || HOMEPAGE === "/") return false;
15
+ redirectFrontendVisit(request, response, HOMEPAGE);
16
+ return true;
17
+ }
18
+
19
+ /**
20
+ * @param {import("./request-target.mjs").ServerRequest} request
21
+ * @param {import("node:http").ServerResponse} response
22
+ * @param {import("./page-props.mjs").PageProps} props
23
+ * @param {import("./module-server.mjs").ServerModules["serverAuth"]} auth
24
+ * @returns {boolean}
25
+ */
26
+ export function redirectSignedOutPrivatePage(request, response, props, auth) {
27
+ const route = pageRoute(props);
28
+ if (
29
+ !isObjectLike(route) ||
30
+ route.publicAccess === true ||
31
+ route.hasAccess !== false ||
32
+ readSession(request)
33
+ )
34
+ return false;
35
+ redirectFrontendVisit(request, response, auth.signInLocation(request.url));
36
+ return true;
37
+ }
@@ -1,5 +1,27 @@
1
+ import { isString } from "./type-guards.mjs";
2
+
1
3
  const SEGMENT = /^[A-Za-z0-9._~-]+$/;
2
4
 
5
+ /** The forms a path pattern takes, as the warnings name them. */
6
+ export const PATH_PATTERN_FORMS = "/x, /x/* or /x/**";
7
+
8
+ /**
9
+ * The wildcard a pattern may end with: what it matches below the literal
10
+ * segments, and how it ranks against a pattern with the same literals.
11
+ */
12
+ const TAILS = new Map([
13
+ ["*", { matcher: "/[^/]+", rank: 3 }],
14
+ ["**", { matcher: "(?:/.*)?", rank: 0 }],
15
+ ]);
16
+ const NO_TAIL = { matcher: "", rank: 1 };
17
+
18
+ /**
19
+ * @typedef {object} CompiledPathPattern
20
+ * @property {string} pattern The pattern as declared
21
+ * @property {number} specificity Orders patterns from the least to the most specific
22
+ * @property {(pathname: string) => boolean} test Whether the pattern covers `pathname`
23
+ */
24
+
3
25
  /**
4
26
  * A pattern made usable, or `undefined` when it is malformed.
5
27
  *
@@ -8,31 +30,32 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
8
30
  *
9
31
  * Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
10
32
  * page itself, `/x/*` one segment below it, `/x/**` the page and everything
11
- * below it. Nothing else: no prefix match inside a segment, no query string.
33
+ * below it. Nothing else: a wildcard only as the last segment, no prefix
34
+ * match inside a segment, no query string.
35
+ *
36
+ * @param {unknown} pattern
37
+ * @returns {CompiledPathPattern | undefined}
12
38
  */
13
39
  export function compilePathPattern(pattern) {
14
- if (typeof pattern !== "string" || !pattern.startsWith("/")) return undefined;
40
+ if (!isString(pattern) || !pattern.startsWith("/")) return undefined;
15
41
  const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
16
- const deep = segments.at(-1) === "**";
17
- const literal = deep ? segments.slice(0, -1) : segments;
42
+ const last = TAILS.get(segments.at(-1) ?? "");
43
+ const literal = last ? segments.slice(0, -1) : segments;
18
44
  if (
19
45
  literal.some(
20
46
  (segment) =>
21
- segment !== "*" &&
22
- (!SEGMENT.test(segment) || segment === "." || segment === ".."),
47
+ !SEGMENT.test(segment) || segment === "." || segment === "..",
23
48
  )
24
49
  )
25
50
  return undefined;
51
+ const tail = last ?? NO_TAIL;
26
52
  const body = literal
27
- .map((segment) =>
28
- segment === "*" ? "/[^/]+" : `/${segment.replaceAll(".", "\\.")}`,
29
- )
53
+ .map((segment) => `/${segment.replaceAll(".", "\\.")}`)
30
54
  .join("");
31
- const matcher = new RegExp(`^${body}${deep ? "(?:/.*)?" : ""}/?$`);
32
- const literals = literal.filter((segment) => segment !== "*").length;
55
+ const matcher = new RegExp(`^${body}${tail.matcher}/?$`);
33
56
  return {
34
57
  pattern,
35
- specificity: literals * 4 + literal.length * 2 + (deep ? 0 : 1),
58
+ specificity: literal.length * 6 + tail.rank,
36
59
  test: (pathname) => matcher.test(pathname === "" ? "/" : pathname),
37
60
  };
38
61
  }