@antelopejs/dms-frontend 0.5.5 → 0.6.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 (75) hide show
  1. package/README.md +377 -19
  2. package/dist/app-configs.d.ts +9 -0
  3. package/dist/app-configs.js +52 -0
  4. package/dist/asset-roots.d.ts +7 -0
  5. package/dist/asset-roots.js +34 -0
  6. package/dist/commands/build-action.js +9 -0
  7. package/dist/commands/dev-action.js +88 -19
  8. package/dist/commands/start-action.js +2 -0
  9. package/dist/common.d.ts +1 -0
  10. package/dist/common.js +1 -0
  11. package/dist/config.d.ts +2 -0
  12. package/dist/config.js +9 -3
  13. package/dist/derived-outputs.d.ts +2 -0
  14. package/dist/derived-outputs.js +38 -31
  15. package/dist/help.js +10 -3
  16. package/dist/layer-watch.js +123 -131
  17. package/dist/layers.js +2 -0
  18. package/dist/manifest-secrets.js +5 -5
  19. package/dist/manifest.js +7 -1
  20. package/dist/materialize.js +21 -1
  21. package/dist/module-set.d.ts +16 -0
  22. package/dist/module-set.js +80 -0
  23. package/dist/server-process.d.ts +1 -0
  24. package/dist/server-process.js +28 -2
  25. package/dist/verify-source-build.d.ts +11 -0
  26. package/dist/verify-source-build.js +113 -0
  27. package/dist/verify-source-runner.js +14 -57
  28. package/dist/workspace.d.ts +9 -0
  29. package/package.json +2 -1
  30. package/templates/vue/app-runtime.ts +58 -16
  31. package/templates/vue/client-server-guard.mjs +73 -0
  32. package/templates/vue/email-renderer.ts +17 -4
  33. package/templates/vue/email-runtime.ts +1 -1
  34. package/templates/vue/frontend-module.ts +1817 -259
  35. package/templates/vue/globals.d.ts +1 -1
  36. package/templates/vue/icon-hydration.ts +106 -1
  37. package/templates/vue/index.html +0 -1
  38. package/templates/vue/layer-app-config.ts +92 -0
  39. package/templates/vue/main.ts +10 -2
  40. package/templates/vue/package.json +5 -1
  41. package/templates/vue/prepare-types.mjs +34 -0
  42. package/templates/vue/server/auth/backend.mjs +13 -19
  43. package/templates/vue/server/auth/client-ip.mjs +27 -0
  44. package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
  45. package/templates/vue/server/auth/session.mjs +6 -97
  46. package/templates/vue/server/auth-hooks.mjs +84 -0
  47. package/templates/vue/server/backend-proxies.mjs +110 -0
  48. package/templates/vue/server/dev-private-files.mjs +99 -0
  49. package/templates/vue/server/email.mjs +11 -33
  50. package/templates/vue/server/favicon.mjs +20 -0
  51. package/templates/vue/server/inertia.mjs +48 -3
  52. package/templates/vue/server/listen-host.mjs +19 -0
  53. package/templates/vue/server/module-event.mjs +265 -0
  54. package/templates/vue/server/module-routes.mjs +513 -0
  55. package/templates/vue/server/module-server.mjs +225 -0
  56. package/templates/vue/server/module-testing.mjs +65 -0
  57. package/templates/vue/server/page-props.mjs +151 -0
  58. package/templates/vue/server/path-patterns.mjs +38 -0
  59. package/templates/vue/server/proxy-headers.mjs +25 -0
  60. package/templates/vue/server/public-endpoints.mjs +69 -11
  61. package/templates/vue/server/request-routing.mjs +47 -0
  62. package/templates/vue/server/route-rules.mjs +129 -0
  63. package/templates/vue/server-module.ts +294 -0
  64. package/templates/vue/server.mjs +178 -169
  65. package/templates/vue/ssr-renderer.ts +60 -19
  66. package/templates/vue/tsconfig.json +2 -1
  67. package/templates/vue/ui-app-config.ts +18 -6
  68. package/templates/vue/ui-inertia-page.mjs +37 -0
  69. package/templates/vue/vite.config.ts +31 -10
  70. package/templates/vue/vite.email.config.ts +1 -1
  71. package/templates/vue/vite.server.config.ts +57 -0
  72. package/templates/vue/server/auth/oauth.mjs +0 -233
  73. package/templates/vue/server/auth/routes.mjs +0 -444
  74. package/templates/vue/server/render-token.mjs +0 -81
  75. package/templates/vue/server/tester.mjs +0 -227
@@ -2,23 +2,29 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { createInertiaApp } from "@inertiajs/vue3";
3
3
  import { createHead, renderSSRHead } from "@unhead/vue/server";
4
4
  import { renderToString } from "@vue/server-renderer";
5
- import { createSSRApp, h } from "vue";
5
+ import { stringify } from "devalue";
6
+ import { createSSRApp, h, readonly } from "vue";
7
+ import { appConfigs } from "./app-configs.generated";
6
8
  import {
9
+ DMS_PAYLOAD_ID,
7
10
  type DmsServerFetch,
8
11
  SSR_ASYNC_COMPONENTS_ID,
9
12
  configureDmsApp,
10
13
  resolveDmsInertiaPage,
11
- serializeDmsAsyncData,
12
14
  trackDmsAsyncComponents,
13
15
  } from "./app-runtime";
14
16
  import {
15
17
  type DmsErrorData,
16
18
  type DmsFrontendRuntime,
19
+ type DmsInertiaPage,
17
20
  type DmsPageProps,
21
+ type DmsPayload,
18
22
  createDmsFrontendRuntime,
19
23
  hasDmsPage,
24
+ pageErrorData,
20
25
  preloadDmsPage,
21
26
  resolveDmsAccessRedirect,
27
+ serializeDmsPayload,
22
28
  setDmsServerRuntimeResolver,
23
29
  setupFrontendModules,
24
30
  trackDmsServerScopes,
@@ -26,6 +32,8 @@ import {
26
32
  import { frontendModules } from "./frontend-modules.generated";
27
33
  import {
28
34
  SSR_ICONS_ID,
35
+ collectDmsIconSymbols,
36
+ dmsIconSprite,
29
37
  serializeDmsIcons,
30
38
  trackDmsIcons,
31
39
  } from "./icon-hydration";
@@ -59,7 +67,6 @@ interface DmsSsrContext {
59
67
  teleports?: Record<string, string>;
60
68
  }
61
69
 
62
- const SSR_ASYNC_DATA_ID = "dms-ssr-async-data";
63
70
  // Holds the runtime rather than being it: every socket, timer and promise a
64
71
  // render starts keeps this store for its whole life, and a pooled backend
65
72
  // connection then serving an SSE stream lives for hours. Emptied once the
@@ -68,13 +75,38 @@ const runtimeStorage = new AsyncLocalStorage<{
68
75
  runtime?: DmsFrontendRuntime;
69
76
  }>();
70
77
  setDmsServerRuntimeResolver(() => runtimeStorage.getStore()?.runtime);
78
+ /** Set while an async data handler runs: its requests are transferred. */
79
+ const asyncDataHandlerStorage = new AsyncLocalStorage<boolean>();
71
80
 
72
- function asyncDataScript(runtime: DmsFrontendRuntime): string {
73
- const value = JSON.stringify(serializeDmsAsyncData(runtime)).replaceAll(
74
- "<",
75
- "\\u003c",
76
- );
77
- return `<script id="${SSR_ASYNC_DATA_ID}" type="application/json">${value}</script>`;
81
+ /**
82
+ * The payload in devalue's format, which keeps what JSON cannot (a Set, a
83
+ * Date). A value it cannot write, such as a class instance a transform
84
+ * returned, is left out rather than failing the page: the browser starts over
85
+ * for that key, as it did before the key was transferred.
86
+ */
87
+ function stringifyPayload(payload: DmsPayload): string {
88
+ try {
89
+ return stringify(payload);
90
+ } catch {
91
+ const kept: DmsPayload = { data: {}, errors: {}, state: {} };
92
+ for (const part of ["data", "errors", "state"] as const)
93
+ for (const [key, value] of Object.entries(payload[part]))
94
+ try {
95
+ stringify(value);
96
+ kept[part][key] = value as never;
97
+ } catch (error) {
98
+ console.error(`DMS payload: "${key}" cannot be serialized`, error);
99
+ }
100
+ return stringify(kept);
101
+ }
102
+ }
103
+
104
+ function payloadScript(payload: DmsPayload): string {
105
+ // Escaped like Inertia's page object, so that no value can end the script.
106
+ const value = stringifyPayload(payload)
107
+ .replaceAll("/", "\\/")
108
+ .replaceAll("<", "\\u003c");
109
+ return `<script id="${DMS_PAYLOAD_ID}" type="application/json">${value}</script>`;
78
110
  }
79
111
 
80
112
  function asyncComponentsScript(names: Set<string>): string {
@@ -90,7 +122,7 @@ function iconsScript(names: Set<string>): string {
90
122
  return `<script id="${SSR_ICONS_ID}" type="application/json">${value}</script>`;
91
123
  }
92
124
 
93
- await setupFrontendModules(frontendModules);
125
+ await setupFrontendModules(frontendModules, appConfigs);
94
126
 
95
127
  export function isDmsFrontendPage(path: string): boolean {
96
128
  return hasDmsPage(path);
@@ -111,6 +143,12 @@ export async function renderDmsPage(
111
143
  true,
112
144
  requestCookies,
113
145
  );
146
+ runtime.runAsyncDataHandler = (run) => asyncDataHandlerStorage.run(true, run);
147
+ runtime.inAsyncDataHandler = () =>
148
+ asyncDataHandlerStorage.getStore() === true;
149
+ runtime.devWarnings = import.meta.env.DEV;
150
+ // The page Inertia's app sets as it starts, held by the request's runtime.
151
+ runtime.inertiaPage = readonly({ flash: {}, ...page }) as DmsInertiaPage;
114
152
  const store = { runtime };
115
153
  try {
116
154
  return await runtimeStorage.run(store, () =>
@@ -133,14 +171,23 @@ async function renderDmsPageWithRuntime(
133
171
  const ssrContext: DmsSsrContext = {};
134
172
  let asyncComponents = new Set<string>();
135
173
  let icons = new Set<string>();
174
+ let iconSymbols = new Map<string, string>();
175
+ let payload: DmsPayload = { data: {}, errors: {}, state: {} };
136
176
  const inertiaResult = await createInertiaApp({
137
177
  page,
138
- render: (app) => renderToString(app, ssrContext),
178
+ // Read as the HTML is complete: data a run settles afterwards was never
179
+ // rendered, and the browser must not hydrate the page with it.
180
+ render: async (app) => {
181
+ const html = await renderToString(app, ssrContext);
182
+ payload = serializeDmsPayload(runtime);
183
+ return html;
184
+ },
139
185
  resolve: resolveDmsInertiaPage,
140
186
  async setup({ App, props, plugin }) {
141
187
  const app = createSSRApp({ render: () => h(App, props) });
142
188
  asyncComponents = trackDmsAsyncComponents(app);
143
189
  icons = trackDmsIcons(app);
190
+ iconSymbols = collectDmsIconSymbols(app);
144
191
  stopScopes = trackDmsServerScopes(app);
145
192
  const configured = await configureDmsApp({
146
193
  app,
@@ -174,13 +221,7 @@ async function renderDmsPageWithRuntime(
174
221
  // the DMS error page describes in the visitor's language; the technical
175
222
  // cause only reaches the server log.
176
223
  if (!expected) console.error("DMS page render failed", failure);
177
- const error = expected
178
- ? {
179
- statusCode: failure.statusCode,
180
- message: failure.message,
181
- statusMessage: failure.statusMessage,
182
- }
183
- : { statusCode: 500 };
224
+ const error = expected ? pageErrorData(failure) : { statusCode: 500 };
184
225
  return renderDmsPage(
185
226
  { ...page, props: { ...page.props, error } },
186
227
  serverFetch,
@@ -190,7 +231,7 @@ async function renderDmsPageWithRuntime(
190
231
  const renderedHead = await renderSSRHead(head);
191
232
  return {
192
233
  error: page.props.error,
193
- body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}${iconsScript(icons)}`,
234
+ body: `${inertiaResult.body}${dmsIconSprite(iconSymbols)}${payloadScript(payload)}${asyncComponentsScript(asyncComponents)}${iconsScript(icons)}`,
194
235
  head: {
195
236
  bodyAttrs: renderedHead.bodyAttrs,
196
237
  headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "globals.d.ts",
18
18
  "auto-imports.d.ts",
19
- "frontend-modules.generated.ts"
19
+ "frontend-modules.generated.ts",
20
+ "app-configs.generated.ts"
20
21
  ],
21
22
  "include": ["frontend-modules/**/*.ts", "frontend-modules/**/*.vue"],
22
23
  "exclude": [
@@ -1,13 +1,21 @@
1
1
  import { defu } from "defu";
2
2
 
3
- type PlainRecord = Record<string, unknown>;
3
+ export type PlainRecord = Record<string, unknown>;
4
4
 
5
5
  interface MergeState {
6
6
  pristine: PlainRecord;
7
7
  mergedFrom: object | null;
8
8
  }
9
9
 
10
- function isPlainRecord(value: unknown): value is PlainRecord {
10
+ interface MergeOptions {
11
+ /**
12
+ * The DMS config was merged before and has changed since, in place: its
13
+ * identity alone cannot tell.
14
+ */
15
+ isChanged?: boolean;
16
+ }
17
+
18
+ export function isPlainRecord(value: unknown): value is PlainRecord {
11
19
  return (
12
20
  typeof value === "object" &&
13
21
  value !== null &&
@@ -15,7 +23,8 @@ function isPlainRecord(value: unknown): value is PlainRecord {
15
23
  );
16
24
  }
17
25
 
18
- function clonePlain<T>(value: T): T {
26
+ /** A deep copy of the records and arrays in `value`, sharing anything else. */
27
+ export function clonePlain<T>(value: T): T {
19
28
  if (Array.isArray(value)) return value.map(clonePlain) as T;
20
29
  if (!isPlainRecord(value)) return value;
21
30
  return Object.fromEntries(
@@ -35,20 +44,23 @@ function replaceInPlace(target: PlainRecord, source: PlainRecord): void {
35
44
  * Each DMS config is merged once, so arrays that defu concatenates never pile
36
45
  * up across renders. A new DMS config, after a development reload, replaces
37
46
  * the previous merge: Nuxt UI's object is reset to its own values first, so
38
- * changed and removed keys both reach the next server render.
47
+ * changed and removed keys both reach the next server render. So does a DMS
48
+ * config a layer's edited app config changed in place, in the browser, once
49
+ * the caller says so.
39
50
  */
40
51
  export function createUiAppConfigMerger(): (
41
52
  uiAppConfig: PlainRecord,
42
53
  dmsAppConfig: object,
54
+ options?: MergeOptions,
43
55
  ) => void {
44
56
  const states = new WeakMap<object, MergeState>();
45
- return (uiAppConfig, dmsAppConfig) => {
57
+ return (uiAppConfig, dmsAppConfig, { isChanged = false } = {}) => {
46
58
  let state = states.get(uiAppConfig);
47
59
  if (state === undefined) {
48
60
  state = { pristine: clonePlain({ ...uiAppConfig }), mergedFrom: null };
49
61
  states.set(uiAppConfig, state);
50
62
  }
51
- if (state.mergedFrom === dmsAppConfig) return;
63
+ if (state.mergedFrom === dmsAppConfig && !isChanged) return;
52
64
  replaceInPlace(
53
65
  uiAppConfig,
54
66
  defu(clonePlain(dmsAppConfig), clonePlain(state.pristine)),
@@ -0,0 +1,37 @@
1
+ // Nuxt UI reads Inertia's page (`usePage()`) for its links and route stub.
2
+ // Inertia's page is module state, shared by the concurrent requests of a
3
+ // server: a render could read another request's URL. Nuxt UI's files read
4
+ // the DMS's per-request page instead, and the build fails if one reads
5
+ // Inertia's in a form this rewrite does not know, rather than letting the
6
+ // leak come back unnoticed with a Nuxt UI release.
7
+
8
+ const INERTIA_PAGE_IMPORT =
9
+ /import\s*\{\s*usePage\s*\}\s*from\s*["']@inertiajs\/vue3["']/g;
10
+ const DMS_PAGE_IMPORT =
11
+ 'import { useDmsInertiaPage as usePage } from "#dms/frontend-module"';
12
+ /** Any import of `usePage` from Inertia, aliased or among other names. */
13
+ const ANY_INERTIA_PAGE_IMPORT =
14
+ /import\s*\{[^}]*\busePage\b[^}]*\}\s*from\s*["']@inertiajs\/vue3["']|import\s*\*\s*as\s+\w+\s+from\s*["']@inertiajs\/vue3["']/;
15
+
16
+ /**
17
+ * The rewrite, as a Vite plugin.
18
+ *
19
+ * @param isNuxtUiFile `(id) => boolean`, whether a module is Nuxt UI's
20
+ */
21
+ export function uiInertiaPage(isNuxtUiFile) {
22
+ return {
23
+ name: "dms-ui-inertia-page",
24
+ enforce: "pre",
25
+ transform(code, id) {
26
+ if (!isNuxtUiFile(id) || !code.includes("@inertiajs/vue3")) return null;
27
+ const rewritten = code.replace(INERTIA_PAGE_IMPORT, DMS_PAGE_IMPORT);
28
+ if (ANY_INERTIA_PAGE_IMPORT.test(rewritten))
29
+ this.error(
30
+ `${id} reads Inertia's page in a form the DMS does not rewrite: ` +
31
+ "a server render could read another request's URL. Update " +
32
+ "ui-inertia-page.mjs for this Nuxt UI release.",
33
+ );
34
+ return rewritten === code ? null : { code: rewritten, map: null };
35
+ },
36
+ };
37
+ }
@@ -9,6 +9,8 @@ import {
9
9
  normalizePath,
10
10
  type Plugin,
11
11
  } from "vite";
12
+ import { rejectServerSourcesInClient } from "./client-server-guard.mjs";
13
+ import { uiInertiaPage } from "./ui-inertia-page.mjs";
12
14
  import { autoImportDirectories } from "./frontend-build-loader";
13
15
  import { frontendBuilds } from "./frontend-builds.generated";
14
16
  import { orderHeadForFirstPaint } from "./head-order.mjs";
@@ -123,10 +125,22 @@ const uiInertiaLinkImport = resolve(
123
125
  __dirname,
124
126
  "node_modules/@nuxt/ui/dist/runtime/vue/overrides/inertia/Link.vue",
125
127
  );
128
+ /**
129
+ * Nuxt UI's Link and route stub read Inertia's page to know the current URL.
130
+ * Inertia keeps a single page for the whole process, written by each server
131
+ * render as it starts: a link rendered after an await could take another
132
+ * request's URL to set its active state. Their `usePage()` is the DMS one,
133
+ * which gives the page of the request being rendered.
134
+ */
135
+ const isNuxtUiFile = (id: string) =>
136
+ normalizePath(id).includes("/node_modules/@nuxt/ui/");
126
137
 
127
138
  export default defineConfig({
128
139
  logLevel,
129
140
  plugins: [
141
+ // Fails a client build that imports a dms.server.ts or a file under a
142
+ // module's server/ directory, however it is imported.
143
+ rejectServerSourcesInClient(moduleRoots),
130
144
  {
131
145
  name: "dms-ui-inertia-link",
132
146
  enforce: "pre",
@@ -134,6 +148,7 @@ export default defineConfig({
134
148
  return id === uiLinkImport ? uiInertiaLinkImport : null;
135
149
  },
136
150
  },
151
+ uiInertiaPage(isNuxtUiFile),
137
152
  vue(),
138
153
  ui({
139
154
  router: "inertia",
@@ -157,25 +172,28 @@ export default defineConfig({
157
172
  // path that cannot exist. Keep it POSIX.
158
173
  [normalizePath(resolve(__dirname, "frontend-module.ts"))]: [
159
174
  "$fetch",
160
- "abortNavigation",
161
- "clearError",
162
- "createError",
163
- "defineAppConfig",
175
+ "abortDmsNavigation",
176
+ "clearDmsError",
177
+ "createDmsError",
178
+ "dmsRequestKey",
179
+ "defineDmsAppConfig",
164
180
  "defineDmsPlugin",
165
181
  "defineDmsMiddleware",
166
182
  "defineDmsPageMeta",
167
183
  "navigateDms",
168
- "prefetchComponents",
169
- "preloadComponents",
184
+ "preloadDmsComponents",
170
185
  "refreshDmsData",
171
- "showError",
186
+ "refreshDmsPageData",
187
+ "setDmsAsyncDataScope",
188
+ "showDmsError",
172
189
  "useDmsAsyncData",
190
+ "useDmsData",
173
191
  "useDmsCookie",
174
192
  "useDmsFetch",
175
193
  "useHead",
176
194
  "useI18n",
177
- "useError",
178
- "useDmsLazyAsyncData",
195
+ "useDmsError",
196
+ "useDmsPagedData",
179
197
  "useDmsPageLoading",
180
198
  "useDmsApp",
181
199
  "useDmsRoute",
@@ -186,7 +204,7 @@ export default defineConfig({
186
204
  "useDmsAppConfig",
187
205
  "DmsClientOnly",
188
206
  "DmsLink",
189
- "useUserSession",
207
+ "useDmsUserSession",
190
208
  ],
191
209
  },
192
210
  ],
@@ -202,6 +220,9 @@ export default defineConfig({
202
220
  dedupe: ["vue", "reka-ui", "@nuxt/ui", "@vueuse/core", "@iconify/vue"],
203
221
  alias: {
204
222
  "#dms/frontend-module": resolve(__dirname, "frontend-module.ts"),
223
+ // For the server's development loader only: no browser module imports
224
+ // a `dms.server.ts`.
225
+ "#dms/server": resolve(__dirname, "server-module.ts"),
205
226
  "#build/nuxt-icon-client-bundle": "virtual:nuxt-ui-icons",
206
227
  "#shortcuts-aggregated": resolve(
207
228
  __dirname,
@@ -69,7 +69,7 @@ export default defineConfig({
69
69
  [normalizePath(resolve(__dirname, "email-runtime.ts"))]: [
70
70
  "useDmsAppConfig",
71
71
  "useDmsRuntimeConfig",
72
- "defineAppConfig",
72
+ "defineDmsAppConfig",
73
73
  ],
74
74
  },
75
75
  ],
@@ -0,0 +1,57 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, resolve } from "node:path";
3
+ import { defineConfig, type LogLevel } from "vite";
4
+
5
+ interface FrontendModuleRegistryEntry {
6
+ id: string;
7
+ root: string;
8
+ }
9
+
10
+ interface FrontendModuleRegistry {
11
+ modules: FrontendModuleRegistryEntry[];
12
+ }
13
+
14
+ const registry = JSON.parse(
15
+ readFileSync(resolve(__dirname, "generated-frontend-modules.json"), "utf8"),
16
+ ) as FrontendModuleRegistry;
17
+ const moduleAliases = Object.fromEntries(
18
+ registry.modules.map((module) => [`@frontend/${module.id}`, module.root]),
19
+ );
20
+ const stableModuleAliases = Object.fromEntries(
21
+ [...registry.modules].reverse().flatMap((module) => {
22
+ const layersRoot = resolve(module.root, "layers");
23
+ if (existsSync(layersRoot)) {
24
+ return readdirSync(layersRoot).map((name) => [
25
+ `#${name}`,
26
+ resolve(layersRoot, name),
27
+ ]);
28
+ }
29
+ const moduleName = basename(module.root).replace(/^.*__/, "");
30
+ return [[`#${moduleName}`, module.root]];
31
+ }),
32
+ );
33
+
34
+ /** Set by `ajs dms build`, as in vite.config.ts. */
35
+ const logLevel = (process.env.DMS_VITE_LOG_LEVEL ?? "info") as LogLevel;
36
+
37
+ /**
38
+ * The modules' server entries (`dms.server.ts`) and what they import, for the
39
+ * production server. No Vue plugin and no auto-imports: server code imports
40
+ * what it uses, and never the application's own code. npm dependencies stay
41
+ * external and load from the workspace's `node_modules`, as for `dist/ssr`.
42
+ */
43
+ export default defineConfig({
44
+ logLevel,
45
+ resolve: {
46
+ alias: {
47
+ "#dms/server": resolve(__dirname, "server-module.ts"),
48
+ ...stableModuleAliases,
49
+ ...moduleAliases,
50
+ },
51
+ },
52
+ build: {
53
+ outDir: "dist/server-modules",
54
+ ssr: "server-modules.generated.ts",
55
+ emptyOutDir: true,
56
+ },
57
+ });
@@ -1,233 +0,0 @@
1
- import { timingSafeEqual } from "node:crypto";
2
- import { backend, json, UpstreamError } from "./backend.mjs";
3
- import { clientIp } from "./client-ip.mjs";
4
- import {
5
- clearSealedCookie,
6
- persistAccountSession,
7
- readSealedCookie,
8
- writeSealedCookie,
9
- } from "./session.mjs";
10
-
11
- const FLOW = "dms_oauth_flow";
12
- const HANDOFF = "dms_oauth_handoff";
13
- const COMPLETE = "/auth/oauth/complete";
14
- const OAUTH_ERROR = "error.oauth.";
15
- const FAILED = "error.oauth.failed";
16
- const FLOW_LIFETIME_MS = 10 * 60 * 1000;
17
- const RATE_WINDOW_MS = 60 * 1000;
18
- const RATE_LIMIT = 10;
19
- const MAX_RATE_LIMIT_CLIENTS = 10_000;
20
- const MAX_CALLBACK_PARAMETER_LENGTH = 2048;
21
- const attempts = new Map();
22
-
23
- export function resolveOAuthClientKey(request) {
24
- return clientIp(request);
25
- }
26
-
27
- function sweepAttempts(now) {
28
- for (const [key, window] of attempts) {
29
- if (now - window.startedAt >= RATE_WINDOW_MS) attempts.delete(key);
30
- }
31
- while (attempts.size >= MAX_RATE_LIMIT_CLIENTS)
32
- attempts.delete(attempts.keys().next().value);
33
- }
34
-
35
- export function hitOAuthRateLimitKey(key, now = Date.now()) {
36
- sweepAttempts(now);
37
- const window = attempts.get(key);
38
- if (!window || now - window.startedAt >= RATE_WINDOW_MS) {
39
- attempts.set(key, { count: 1, startedAt: now });
40
- return true;
41
- }
42
- window.count += 1;
43
- return window.count <= RATE_LIMIT;
44
- }
45
-
46
- function rateLimited(request, scope) {
47
- const now = Date.now();
48
- return !hitOAuthRateLimitKey(
49
- `${scope}:${resolveOAuthClientKey(request)}`,
50
- now,
51
- );
52
- }
53
-
54
- export function isValidOAuthProvider(provider) {
55
- return typeof provider === "string" && /^[a-z0-9-]{1,32}$/.test(provider);
56
- }
57
-
58
- function safePath(value) {
59
- if (
60
- typeof value !== "string" ||
61
- !value.startsWith("/") ||
62
- value.startsWith("//") ||
63
- value.includes("\\")
64
- )
65
- return "";
66
- try {
67
- const decoded = decodeURIComponent(value);
68
- return decoded.startsWith("//") || decoded.includes("\\") ? "" : value;
69
- } catch {
70
- return "";
71
- }
72
- }
73
-
74
- function redirect(response, location) {
75
- response.writeHead(302, { location });
76
- response.end();
77
- }
78
-
79
- function errorRedirect(response, key) {
80
- redirect(response, `/auth?oauth_error=${encodeURIComponent(key)}`);
81
- }
82
-
83
- // The backend's own OAuth key says why a provider sign-in failed; any other
84
- // failure keeps the generic one.
85
- function failureRedirect(response, error) {
86
- errorRedirect(
87
- response,
88
- error instanceof UpstreamError && error.message.startsWith(OAUTH_ERROR)
89
- ? error.message
90
- : FAILED,
91
- );
92
- }
93
-
94
- export async function oauthStart(request, response, provider, url) {
95
- if (
96
- !isValidOAuthProvider(provider) ||
97
- request.headers["sec-fetch-site"] === "cross-site" ||
98
- rateLimited(request, "start")
99
- )
100
- return errorRedirect(response, FAILED);
101
- try {
102
- const result = await backend(
103
- `/api/auth/oauth/${provider}/authorize-url`,
104
- request,
105
- { relay: true },
106
- );
107
- writeSealedCookie(
108
- response,
109
- FLOW,
110
- {
111
- state: result.state,
112
- expiresAt: Date.now() + FLOW_LIFETIME_MS,
113
- redirect: safePath(url.searchParams.get("redirect")),
114
- language: url.searchParams.get("language") ?? "",
115
- invite: url.searchParams.get("invite") ?? "",
116
- },
117
- FLOW_LIFETIME_MS / 1000,
118
- );
119
- redirect(response, result.authorizeUrl);
120
- } catch (error) {
121
- failureRedirect(response, error);
122
- }
123
- }
124
-
125
- function sameState(left, right) {
126
- if (typeof left !== "string" || typeof right !== "string") return false;
127
- const a = Buffer.from(left);
128
- const b = Buffer.from(right);
129
- return a.length === b.length && timingSafeEqual(a, b);
130
- }
131
-
132
- function validCallback(flow, url) {
133
- const code = url.searchParams.get("code");
134
- const state = url.searchParams.get("state");
135
- return !(
136
- !flow ||
137
- flow.expiresAt < Date.now() ||
138
- typeof code !== "string" ||
139
- code.length > MAX_CALLBACK_PARAMETER_LENGTH ||
140
- typeof state !== "string" ||
141
- state.length > MAX_CALLBACK_PARAMETER_LENGTH ||
142
- !sameState(state, flow.state)
143
- );
144
- }
145
-
146
- function storeOAuthResult(request, response, provider, result) {
147
- if (result.requires_2fa)
148
- writeSealedCookie(
149
- response,
150
- HANDOFF,
151
- { kind: "2fa", token: result.two_factor_token, methods: result.methods },
152
- 300,
153
- );
154
- else if (result.requires_tenant_assignment)
155
- writeSealedCookie(
156
- response,
157
- HANDOFF,
158
- { kind: "no-workspace", token: result.tenant_assignment_token, provider },
159
- 300,
160
- );
161
- else persistAccountSession(request, response, sessionFrom(result));
162
- }
163
-
164
- async function completeOAuth(request, response, provider, url, flow) {
165
- try {
166
- const result = await backend(
167
- `/api/auth/oauth/${provider}/callback`,
168
- request,
169
- {
170
- method: "POST",
171
- relay: true,
172
- body: {
173
- code: url.searchParams.get("code"),
174
- state: url.searchParams.get("state"),
175
- state_cookie: flow.state,
176
- language: flow.language || undefined,
177
- invite: flow.invite || undefined,
178
- },
179
- },
180
- );
181
- storeOAuthResult(request, response, provider, result);
182
- redirect(
183
- response,
184
- flow.redirect &&
185
- !result.requires_2fa &&
186
- !result.requires_tenant_assignment
187
- ? `${COMPLETE}?redirect=${encodeURIComponent(flow.redirect)}`
188
- : COMPLETE,
189
- );
190
- } catch (error) {
191
- failureRedirect(response, error);
192
- }
193
- }
194
-
195
- export async function oauthCallback(request, response, provider, url) {
196
- if (!isValidOAuthProvider(provider) || rateLimited(request, "callback"))
197
- return errorRedirect(response, FAILED);
198
- const flow = readSealedCookie(request, FLOW);
199
- clearSealedCookie(response, FLOW);
200
- if (url.searchParams.has("error"))
201
- return errorRedirect(response, "error.oauth.cancelled");
202
- if (!validCallback(flow, url))
203
- return errorRedirect(response, "error.oauth.invalid_state");
204
- return completeOAuth(request, response, provider, url, flow);
205
- }
206
-
207
- export function oauthHandoff(request, response) {
208
- const handoff = readSealedCookie(request, HANDOFF) ?? { kind: "none" };
209
- clearSealedCookie(response, HANDOFF);
210
- json(response, 200, handoff);
211
- }
212
-
213
- /**
214
- * The session a token pair opens.
215
- *
216
- * @param result Backend answer carrying the tokens and the user
217
- * @param persistent Whether the session outlives the browser
218
- */
219
- export function sessionFrom(result, persistent = true) {
220
- const payload = JSON.parse(
221
- Buffer.from(
222
- result.access_token.split(".")[1] ?? "",
223
- "base64url",
224
- ).toString() || "{}",
225
- );
226
- return {
227
- user: result.user,
228
- accessToken: result.access_token,
229
- refreshToken: result.refresh_token,
230
- activeTenantId: payload.tenantId ?? "default",
231
- persistent,
232
- };
233
- }