@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
@@ -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, 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,18 +80,29 @@ 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. */
83
+ /**
84
+ * The object holding `path`'s last key, when every key up to it is own.
85
+ *
86
+ * @param {Record<string, unknown>} target
87
+ * @param {string[]} path
88
+ */
51
89
  function holderOf(target, path) {
52
90
  let node = target;
53
91
  for (const key of path.slice(0, -1)) {
54
92
  if (UNSAFE_KEYS.has(key) || !Object.hasOwn(node, key)) return undefined;
55
93
  node = node[key];
56
- if (typeof node !== "object" || node === null) return undefined;
94
+ if (!isNonNullObject(node)) return undefined;
57
95
  }
58
96
  return UNSAFE_KEYS.has(path.at(-1)) ? undefined : node;
59
97
  }
60
98
 
61
- /** Sets `value` at `path` when the object holding it exists but lacks it. */
99
+ /**
100
+ * Sets `value` at `path` when the object holding it exists but lacks it.
101
+ *
102
+ * @param {Record<string, unknown>} target
103
+ * @param {string[]} path
104
+ * @param {string} value
105
+ */
62
106
  function fillPath(target, path, value) {
63
107
  const holder = holderOf(target, path);
64
108
  const key = path.at(-1);
@@ -66,7 +110,13 @@ function fillPath(target, path, value) {
66
110
  holder[key] = value;
67
111
  }
68
112
 
69
- /** Sets `value` at `path` only where the build left an empty string. */
113
+ /**
114
+ * Sets `value` at `path` only where the build left an empty string.
115
+ *
116
+ * @param {Record<string, unknown>} target
117
+ * @param {string[]} path
118
+ * @param {string} value
119
+ */
70
120
  function fillEmptied(target, path, value) {
71
121
  const holder = holderOf(target, path);
72
122
  const key = path.at(-1);
@@ -79,10 +129,10 @@ function fillEmptied(target, path, value) {
79
129
  * the environment variable of a secret (`DMS_HTML_RENDER_SECRET`,
80
130
  * `DMS_OAUTH_RELAY_SECRET`) only fills in a section that carries none.
81
131
  *
82
- * @param modules The private options by module id, as the manifest published
132
+ * @param {Record<string, Record<string, unknown>>} modules The private options by module id, as the manifest published
83
133
  * them
84
- * @param environment The variables by the path of the secret they stand for
85
- * @param env The process environment
134
+ * @param {Record<string, string[]>} environment The variables by the path of the secret they stand for
135
+ * @param {NodeJS.ProcessEnv} env The process environment
86
136
  */
87
137
  export function privateOptionsWithFallbacks(modules, environment, env) {
88
138
  for (const options of Object.values(modules))
@@ -91,10 +141,16 @@ export function privateOptionsWithFallbacks(modules, environment, env) {
91
141
  return modules;
92
142
  }
93
143
 
94
- /** Each string leaf of `value`, array items included, with its path. */
144
+ /**
145
+ * Each string leaf of `value`, array items included, with its path.
146
+ *
147
+ * @param {unknown} value
148
+ * @param {string[]} [path]
149
+ * @returns {Generator<[string[], string]>}
150
+ */
95
151
  function* stringLeaves(value, path = []) {
96
- if (typeof value === "string") yield [path, value];
97
- else if (typeof value === "object" && value !== null)
152
+ if (isString(value)) yield [path, value];
153
+ else if (isNonNullObject(value))
98
154
  for (const [key, child] of Object.entries(value))
99
155
  yield* stringLeaves(child, [...path, key]);
100
156
  }
@@ -107,19 +163,20 @@ function* stringLeaves(value, path = []) {
107
163
  * a value the build emptied is filled, array items included: a value the
108
164
  * manifest carries wins, and no option or section is added or replaced.
109
165
  *
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
166
+ * @param {Record<string, Record<string, unknown>>} modules The private options by module id
167
+ * @param {string | undefined} json The variable's value; nothing is filled when unset
168
+ * @param {(message: string) => void} warn Reports a value that is not a JSON object, never its content
113
169
  */
114
170
  export function privateOptionsFromJson(modules, json, warn) {
115
171
  if (!json) return modules;
172
+ /** @type {unknown} */
116
173
  let given;
117
174
  try {
118
175
  given = JSON.parse(json);
119
176
  } catch {
120
177
  given = undefined;
121
178
  }
122
- if (typeof given !== "object" || given === null || Array.isArray(given)) {
179
+ if (!isNonNullObject(given) || Array.isArray(given)) {
123
180
  warn("DMS_PRIVATE_OPTIONS is not a JSON object of options by module id");
124
181
  return modules;
125
182
  }
@@ -131,10 +188,10 @@ export function privateOptionsFromJson(modules, json, warn) {
131
188
  return modules;
132
189
  }
133
190
 
191
+ /** @param {NodeJS.ProcessEnv} [env] */
134
192
  function readPrivateOptions(env = process.env) {
135
- const { modules = {}, environment = {} } = readJsonFile(
136
- SERVER_OPTIONS_FILE,
137
- {},
193
+ const { modules, environment } = serverOptionsOf(
194
+ readJsonFile(SERVER_OPTIONS_FILE, {}),
138
195
  );
139
196
  return privateOptionsFromJson(
140
197
  privateOptionsWithFallbacks(modules, environment, env),
@@ -144,14 +201,22 @@ function readPrivateOptions(env = process.env) {
144
201
  }
145
202
 
146
203
  function readPublicOptions() {
147
- return publicOptionsOf(readJsonFile(REGISTRY_FILE, { modules: [] }));
204
+ return publicOptionsOf(
205
+ frontendRegistryOf(readJsonFile(REGISTRY_FILE, { modules: [] })),
206
+ );
148
207
  }
149
208
 
209
+ /**
210
+ * @param {import("vite").ViteDevServer | undefined} devServer
211
+ * @returns {Promise<ServerModuleDeclaration[]>}
212
+ */
150
213
  async function loadDeclarations(devServer) {
151
214
  if (devServer)
215
+ // 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
216
  return (await devServer.ssrLoadModule(SOURCE_LOADER)).serverModules;
153
217
  if (!existsSync(fileURLToPath(BUILT_LOADER))) return [];
154
218
  builtDeclarations ??= import(BUILT_LOADER.href).then(
219
+ // 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
220
  (loaded) => loaded.serverModules,
156
221
  );
157
222
  return builtDeclarations;
@@ -161,6 +226,8 @@ async function loadDeclarations(devServer) {
161
226
  * The declarations to serve. A module entry that fails to load in development
162
227
  * (a syntax error being typed) is reported once, and the last routes that
163
228
  * loaded keep answering meanwhile.
229
+ *
230
+ * @param {import("vite").ViteDevServer | undefined} devServer
164
231
  */
165
232
  async function currentDeclarations(devServer) {
166
233
  try {
@@ -181,8 +248,8 @@ async function currentDeclarations(devServer) {
181
248
  *
182
249
  * `authOwner` is the module whose sealed cookies are the `dms_*` ones.
183
250
  *
184
- * @param declarations `{ id, priority, setup }` per module with a dms.server.ts
185
- * @param deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
251
+ * @param {readonly ServerModuleDeclaration[]} declarations `{ id, priority, setup }` per module with a dms.server.ts
252
+ * @param {Deployment} deployment `{ publicOptions, privateOptions, authEstablishEndpoints,
186
253
  * publicEndpoints, backendProxies, warn }`: each module's options by id,
187
254
  * the session-opening endpoints as a function (the environment can change
188
255
  * them), the public endpoints, the backend proxies the manifest declares
@@ -198,7 +265,7 @@ export function createServerModules(declarations, deployment) {
198
265
  deployment.publicEndpoints ?? [],
199
266
  deployment.backendProxies ?? [],
200
267
  );
201
- const context = (id) => ({
268
+ const context = (/** @type {string} */ id) => ({
202
269
  module: {
203
270
  id,
204
271
  options: deployment.publicOptions?.[id],
@@ -216,7 +283,11 @@ export function createServerModules(declarations, deployment) {
216
283
  };
217
284
  }
218
285
 
219
- /** Resolved once per set of declarations: in development, once per edit. */
286
+ /**
287
+ * Resolved once per set of declarations: in development, once per edit.
288
+ *
289
+ * @param {ServerModuleDeclaration[]} declarations
290
+ */
220
291
  function serverModules(declarations) {
221
292
  let modules = resolved.get(declarations);
222
293
  if (!modules) {
@@ -245,7 +316,7 @@ function serverModules(declarations) {
245
316
  * next request; in production from the bundle `ajs dms build` makes
246
317
  * (`dist/server-modules`). A workspace built before the feature has none.
247
318
  *
248
- * @param devServer Vite in development, `undefined` in production
319
+ * @param {import("vite").ViteDevServer | undefined} devServer Vite in development, `undefined` in production
249
320
  */
250
321
  export async function loadServerModules(devServer) {
251
322
  return serverModules(await currentDeclarations(devServer));
@@ -256,10 +327,10 @@ export async function loadServerModules(devServer) {
256
327
  * module route serves is the module's for every method: another method is
257
328
  * answered `405`.
258
329
  *
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
330
+ * @param {import("node:http").IncomingMessage} request The Node request
331
+ * @param {import("node:http").ServerResponse} response The Node response
332
+ * @param {string} pathname The request's normalized path
333
+ * @param {ServerModules} modules What `loadServerModules` or `createServerModules` returned
263
334
  * @returns Whether a module route answered
264
335
  */
265
336
  export async function handleModuleRoute(request, response, pathname, modules) {
@@ -2,10 +2,16 @@ 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 } from "./request-failure.mjs";
5
6
 
6
- /** What the frontend server answers an API request whose handler failed. */
7
+ /**
8
+ * What the frontend server answers an API request whose handler failed.
9
+ *
10
+ * @param {unknown} error
11
+ * @param {import("node:http").ServerResponse} response
12
+ */
7
13
  function writeFailure(error, response) {
8
- const status = Number.isInteger(error?.status) ? error.status : 502;
14
+ const status = failureStatus(error);
9
15
  const body =
10
16
  error instanceof UpstreamError || error instanceof RequestBodyError
11
17
  ? { message: error.message }
@@ -26,9 +32,9 @@ function writeFailure(error, response) {
26
32
  * `DMS_API_BASE_URL` and `DMS_SESSION_SECRET` from the environment, as in
27
33
  * production.
28
34
  *
29
- * @param declarations `{ id, priority?, setup }` per module, `setup` being
35
+ * @param {readonly import("./module-server.mjs").ServerModuleDeclaration[]} declarations `{ id, priority?, setup }` per module, `setup` being
30
36
  * the default export of its `dms.server.ts`
31
- * @param deployment `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
37
+ * @param {Omit<import("./module-server.mjs").Deployment, "warn" | "authEstablishEndpoints"> & { authEstablishEndpoints?: readonly string[] }} [deployment] `{ publicOptions?, privateOptions?, authEstablishEndpoints?,
32
38
  * publicEndpoints?, backendProxies? }`, options by module id, and the
33
39
  * manifest's backend proxies (`{ module, path, auth? }`), which decide
34
40
  * the backend redirects relayed as frontend paths. The private options are
@@ -37,6 +43,7 @@ function writeFailure(error, response) {
37
43
  * @returns `{ url, modules, warnings, close() }`
38
44
  */
39
45
  export async function serveModuleRoutes(declarations, deployment = {}) {
46
+ /** @type {string[]} */
40
47
  const warnings = [];
41
48
  const endpoints = deployment.authEstablishEndpoints ?? [];
42
49
  const modules = createServerModules(declarations, {
@@ -50,7 +57,9 @@ export async function serveModuleRoutes(declarations, deployment = {}) {
50
57
  .then((answered) => {
51
58
  if (!answered) response.writeHead(404).end();
52
59
  })
53
- .catch((error) => writeFailure(error, response));
60
+ .catch(
61
+ /** @param {unknown} error */ (error) => writeFailure(error, response),
62
+ );
54
63
  });
55
64
  await new Promise((settle) => server.listen(0, "127.0.0.1", settle));
56
65
  const { port } = server.address();
@@ -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,58 @@ 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: unknown) => { 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 {unknown} session The sealed session: any JSON value, since only its
70
+ * `accountId` and `activeTenantId` are read, as empty when it holds none
71
+ * @returns {string}
22
72
  */
23
73
  export function sharedOnceKey(session) {
24
74
  const identity = `${session?.accountId ?? ""}\n${session?.activeTenantId ?? ""}`;
@@ -30,6 +80,10 @@ export function sharedOnceKey(session) {
30
80
  * Whether the browser holds this session's layout and no other: a browser
31
81
  * that holds another session's key too (a switch back and forth without a
32
82
  * full visit) may show that one under `shared`, so it is sent this one.
83
+ *
84
+ * @param {Set<string>} held
85
+ * @param {string} sharedKey
86
+ * @returns {boolean}
33
87
  */
34
88
  function holdsOnlyThisLayout(held, sharedKey) {
35
89
  if (!held.has(sharedKey)) return false;
@@ -50,9 +104,14 @@ const PAGE_REQUEST_PARAMETERS = new Set(["path", SHARED_PROP]);
50
104
  * on), so the layout is resolved for the query the browser shows and the
51
105
  * browser does not ask it again. One value per name, the last, as the
52
106
  * browser's own page layout request sends it.
107
+ *
108
+ * @param {URL} url
109
+ * @param {boolean} withShared
110
+ * @returns {string}
53
111
  */
54
112
  function pagePath(url, withShared) {
55
113
  const query = new URLSearchParams({ path: url.pathname });
114
+ /** @type {Map<string, string>} */
56
115
  const pageQuery = new Map();
57
116
  for (const [name, value] of url.searchParams) {
58
117
  if (!PAGE_REQUEST_PARAMETERS.has(name)) pageQuery.set(name, value);
@@ -62,7 +121,12 @@ function pagePath(url, withShared) {
62
121
  return `/dms/page?${query}`;
63
122
  }
64
123
 
65
- /** A page only the frontend has: a custom page, with no backend data. */
124
+ /**
125
+ * A page only the frontend has: a custom page, with no backend data.
126
+ *
127
+ * @param {string} pathname
128
+ * @param {string} signInPath
129
+ */
66
130
  function frontendOnlyPage(pathname, signInPath) {
67
131
  return {
68
132
  componentName: pathname,
@@ -76,6 +140,13 @@ function frontendOnlyPage(pathname, signInPath) {
76
140
  };
77
141
  }
78
142
 
143
+ /**
144
+ * @param {URL} url
145
+ * @param {boolean} withShared
146
+ * @param {PageBackend} backend
147
+ * @param {string} signInPath
148
+ * @returns {Promise<{ page: PageProps["page"], shared: unknown }>}
149
+ */
79
150
  async function loadPage(
80
151
  url,
81
152
  withShared,
@@ -84,7 +155,10 @@ async function loadPage(
84
155
  ) {
85
156
  const { pathname } = url;
86
157
  try {
87
- const { shared, ...page } = await fetchJson(pagePath(url, withShared));
158
+ const answer = await fetchJson(pagePath(url, withShared));
159
+ if (!isRecord(answer) || Array.isArray(answer))
160
+ throw new UpstreamError(502, GENERIC_ERROR_MESSAGE);
161
+ const { shared, ...page } = answer;
88
162
  return { page, shared };
89
163
  } catch (error) {
90
164
  if (!(error instanceof BackendResponseError) || error.status !== NOT_FOUND)
@@ -101,6 +175,12 @@ async function loadPage(
101
175
  * The props of the same page at another query of its path (`url`): the page
102
176
  * is read again, since its layout reads the query, and the rest is kept,
103
177
  * the site layout included.
178
+ *
179
+ * @param {PageProps} props
180
+ * @param {URL} url
181
+ * @param {PageBackend} backend
182
+ * @param {PageAuth} auth
183
+ * @returns {Promise<PageProps>}
104
184
  */
105
185
  export async function propsForQuery(props, url, backend, auth) {
106
186
  const { page } = await loadPage(url, false, backend, auth.signInPath);
@@ -125,6 +205,14 @@ export async function propsForQuery(props, url, backend, auth) {
125
205
  * The shared layout is declared only when the browser ends up with this
126
206
  * session's layout, sent now or already held: declaring it otherwise would let
127
207
  * a layout of another session pass for this one.
208
+ *
209
+ * @param {import("node:http").IncomingMessage} request
210
+ * @param {PageBackend} backend
211
+ * @param {PageAuth} auth
212
+ * @returns {Promise<{
213
+ * props: PageProps,
214
+ * onceProps: import("./inertia.mjs").OnceProps | undefined
215
+ * }>}
128
216
  */
129
217
  export async function loadPageProps(request, backend, auth) {
130
218
  const url = new URL(request.url, "http://frontend.local");
@@ -135,10 +223,12 @@ export async function loadPageProps(request, backend, auth) {
135
223
  const sharedKey = sharedOnceKey(sealed);
136
224
  const holdsShared = holdsOnlyThisLayout(heldOnceProps(request), sharedKey);
137
225
  const withShared = (partial && wants(SHARED_PROP)) || !holdsShared;
226
+ /** @type {PageProps} */
138
227
  const props = {};
139
228
  if (wants("path")) props.path = pathname;
140
229
  if (wants("user") && (user || partial)) props.user = user ?? null;
141
230
  if (wants("session") && (session || partial)) props.session = session ?? null;
231
+ /** @type {unknown} */
142
232
  let shared;
143
233
  if (wants("page")) {
144
234
  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("node:http").IncomingMessage} 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,14 @@
1
+ import { isString } from "./type-guards.mjs";
2
+
1
3
  const SEGMENT = /^[A-Za-z0-9._~-]+$/;
2
4
 
5
+ /**
6
+ * @typedef {object} CompiledPathPattern
7
+ * @property {string} pattern The pattern as declared
8
+ * @property {number} specificity Orders patterns from the least to the most specific
9
+ * @property {(pathname: string) => boolean} test Whether the pattern covers `pathname`
10
+ */
11
+
3
12
  /**
4
13
  * A pattern made usable, or `undefined` when it is malformed.
5
14
  *
@@ -9,9 +18,12 @@ const SEGMENT = /^[A-Za-z0-9._~-]+$/;
9
18
  * Page path patterns, as the modules' `dms.server.ts` name them: `/x` is the
10
19
  * page itself, `/x/*` one segment below it, `/x/**` the page and everything
11
20
  * below it. Nothing else: no prefix match inside a segment, no query string.
21
+ *
22
+ * @param {unknown} pattern
23
+ * @returns {CompiledPathPattern | undefined}
12
24
  */
13
25
  export function compilePathPattern(pattern) {
14
- if (typeof pattern !== "string" || !pattern.startsWith("/")) return undefined;
26
+ if (!isString(pattern) || !pattern.startsWith("/")) return undefined;
15
27
  const segments = pattern === "/" ? [] : pattern.slice(1).split("/");
16
28
  const deep = segments.at(-1) === "**";
17
29
  const literal = deep ? segments.slice(0, -1) : segments;
@@ -20,11 +20,22 @@ const COMPRESSIBLE_TYPE =
20
20
  /^(?:text\/(?!event-stream)[\w.+-]+|application\/(?:[\w.-]+\+)?(?:json|xml|javascript|ecmascript)|image\/svg\+xml)\s*(?:;|$)/i;
21
21
  const BODILESS_STATUSES = new Set([204, 205, 304]);
22
22
 
23
+ /**
24
+ * What the proxy relays of a backend answer: a fetched `Response`, or the
25
+ * same fields without its content coding.
26
+ *
27
+ * @typedef {Pick<Response, "status" | "headers" | "body" | "url">} ProxiedAnswer
28
+ */
29
+
23
30
  /**
24
31
  * Whether the backend answer is one to compress on its way to the client.
25
32
  *
26
33
  * A response already encoded, or a range of the bytes, is not compressed:
27
34
  * compressing would change what the encoding or the range describes.
35
+ *
36
+ * @param {import("node:http").IncomingMessage} request
37
+ * @param {ProxiedAnswer} upstream
38
+ * @returns {boolean}
28
39
  */
29
40
  function isCompressible(request, upstream) {
30
41
  if (request.method === "HEAD" || !upstream.body) return false;
@@ -48,6 +59,9 @@ function isCompressible(request, upstream) {
48
59
  *
49
60
  * Streaming: an answer of unknown length (chunked JSON, streamed text) reaches
50
61
  * the client part by part, as the backend writes it.
62
+ *
63
+ * @param {import("node:http").IncomingMessage} request
64
+ * @param {ProxiedAnswer} upstream
51
65
  */
52
66
  function proxyEncoder(request, upstream) {
53
67
  return isCompressible(request, upstream)
@@ -64,6 +78,14 @@ function proxyEncoder(request, upstream) {
64
78
  *
65
79
  * A decoded partial representation cannot retain the encoded byte offsets, and
66
80
  * a complete 200 response is not manufactured from a partial 206 either.
81
+ *
82
+ * @param {import("node:http").IncomingMessage} request
83
+ * @param {import("node:http").ServerResponse} response
84
+ * @param {ProxiedAnswer} upstream
85
+ * @param {{
86
+ * fetched?: boolean,
87
+ * isProxiedPath?: (pathname: string) => boolean
88
+ * }} [options]
67
89
  */
68
90
  export async function relayAnswer(
69
91
  request,
@@ -15,7 +15,11 @@ const HOP_BY_HOP_HEADERS = [
15
15
  "upgrade",
16
16
  ];
17
17
 
18
- /** `Accept-Encoding` added to the backend's own `Vary`, once. */
18
+ /**
19
+ * `Accept-Encoding` added to the backend's own `Vary`, once.
20
+ *
21
+ * @param {string | undefined} vary
22
+ */
19
23
  function varyWithEncoding(vary) {
20
24
  const names = (vary ?? "")
21
25
  .split(",")
@@ -32,6 +36,12 @@ function varyWithEncoding(vary) {
32
36
  * known, and a strong validator no longer names these bytes. A `Location` on
33
37
  * the backend's origin becomes the same path on the frontend's when
34
38
  * `isProxiedPath` says the frontend proxies it (see `relayedLocation`).
39
+ *
40
+ * @param {import("node:http").ServerResponse} response
41
+ * @param {Pick<Response, "status" | "headers" | "url">} upstream
42
+ * @param {string | undefined} encoding
43
+ * @param {boolean} [varies]
44
+ * @param {(pathname: string) => boolean} [isProxiedPath]
35
45
  */
36
46
  export function writeProxyHeaders(
37
47
  response,