@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.
- package/README.md +377 -19
- package/dist/app-configs.d.ts +9 -0
- package/dist/app-configs.js +52 -0
- package/dist/asset-roots.d.ts +7 -0
- package/dist/asset-roots.js +34 -0
- package/dist/commands/build-action.js +9 -0
- package/dist/commands/dev-action.js +88 -19
- package/dist/commands/start-action.js +2 -0
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -3
- package/dist/derived-outputs.d.ts +2 -0
- package/dist/derived-outputs.js +38 -31
- package/dist/help.js +10 -3
- package/dist/layer-watch.js +123 -131
- package/dist/layers.js +2 -0
- package/dist/manifest-secrets.js +5 -5
- package/dist/manifest.js +7 -1
- package/dist/materialize.js +21 -1
- package/dist/module-set.d.ts +16 -0
- package/dist/module-set.js +80 -0
- package/dist/server-process.d.ts +1 -0
- package/dist/server-process.js +28 -2
- package/dist/verify-source-build.d.ts +11 -0
- package/dist/verify-source-build.js +113 -0
- package/dist/verify-source-runner.js +14 -57
- package/dist/workspace.d.ts +9 -0
- package/package.json +2 -1
- package/templates/vue/app-runtime.ts +58 -16
- package/templates/vue/client-server-guard.mjs +73 -0
- package/templates/vue/email-renderer.ts +17 -4
- package/templates/vue/email-runtime.ts +1 -1
- package/templates/vue/frontend-module.ts +1817 -259
- package/templates/vue/globals.d.ts +1 -1
- package/templates/vue/icon-hydration.ts +106 -1
- package/templates/vue/index.html +0 -1
- package/templates/vue/layer-app-config.ts +92 -0
- package/templates/vue/main.ts +10 -2
- package/templates/vue/package.json +5 -1
- package/templates/vue/prepare-types.mjs +34 -0
- package/templates/vue/server/auth/backend.mjs +13 -19
- package/templates/vue/server/auth/client-ip.mjs +27 -0
- package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
- package/templates/vue/server/auth/session.mjs +6 -97
- package/templates/vue/server/auth-hooks.mjs +84 -0
- package/templates/vue/server/backend-proxies.mjs +110 -0
- package/templates/vue/server/dev-private-files.mjs +99 -0
- package/templates/vue/server/email.mjs +11 -33
- package/templates/vue/server/favicon.mjs +20 -0
- package/templates/vue/server/inertia.mjs +48 -3
- package/templates/vue/server/listen-host.mjs +19 -0
- package/templates/vue/server/module-event.mjs +265 -0
- package/templates/vue/server/module-routes.mjs +513 -0
- package/templates/vue/server/module-server.mjs +225 -0
- package/templates/vue/server/module-testing.mjs +65 -0
- package/templates/vue/server/page-props.mjs +151 -0
- package/templates/vue/server/path-patterns.mjs +38 -0
- package/templates/vue/server/proxy-headers.mjs +25 -0
- package/templates/vue/server/public-endpoints.mjs +69 -11
- package/templates/vue/server/request-routing.mjs +47 -0
- package/templates/vue/server/route-rules.mjs +129 -0
- package/templates/vue/server-module.ts +294 -0
- package/templates/vue/server.mjs +178 -169
- package/templates/vue/ssr-renderer.ts +60 -19
- package/templates/vue/tsconfig.json +2 -1
- package/templates/vue/ui-app-config.ts +18 -6
- package/templates/vue/ui-inertia-page.mjs +37 -0
- package/templates/vue/vite.config.ts +31 -10
- package/templates/vue/vite.email.config.ts +1 -1
- package/templates/vue/vite.server.config.ts +57 -0
- package/templates/vue/server/auth/oauth.mjs +0 -233
- package/templates/vue/server/auth/routes.mjs +0 -444
- package/templates/vue/server/render-token.mjs +0 -81
- 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 {
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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}${
|
|
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}`,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
175
|
+
"abortDmsNavigation",
|
|
176
|
+
"clearDmsError",
|
|
177
|
+
"createDmsError",
|
|
178
|
+
"dmsRequestKey",
|
|
179
|
+
"defineDmsAppConfig",
|
|
164
180
|
"defineDmsPlugin",
|
|
165
181
|
"defineDmsMiddleware",
|
|
166
182
|
"defineDmsPageMeta",
|
|
167
183
|
"navigateDms",
|
|
168
|
-
"
|
|
169
|
-
"preloadComponents",
|
|
184
|
+
"preloadDmsComponents",
|
|
170
185
|
"refreshDmsData",
|
|
171
|
-
"
|
|
186
|
+
"refreshDmsPageData",
|
|
187
|
+
"setDmsAsyncDataScope",
|
|
188
|
+
"showDmsError",
|
|
172
189
|
"useDmsAsyncData",
|
|
190
|
+
"useDmsData",
|
|
173
191
|
"useDmsCookie",
|
|
174
192
|
"useDmsFetch",
|
|
175
193
|
"useHead",
|
|
176
194
|
"useI18n",
|
|
177
|
-
"
|
|
178
|
-
"
|
|
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
|
-
"
|
|
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,
|
|
@@ -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
|
-
}
|