@antelopejs/dms-frontend 0.3.4 → 0.3.6
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 +19 -0
- package/dist/config.js +3 -0
- package/dist/fs-sync.js +3 -5
- package/dist/manifest.d.ts +3 -0
- package/dist/manifest.js +12 -3
- package/dist/ports.js +1 -1
- package/dist/renderer-range.js +6 -1
- package/dist/verify-source-runner.js +7 -1
- package/package.json +3 -1
- package/templates/vue/app-runtime.ts +3 -13
- package/templates/vue/frontend-module.ts +0 -11
- package/templates/vue/icon-api.mjs +15 -0
- package/templates/vue/icon-scan.mjs +31 -0
- package/templates/vue/main.ts +5 -0
- package/templates/vue/package.json +4 -0
- package/templates/vue/server/content.mjs +47 -0
- package/templates/vue/server/icons.mjs +124 -0
- package/templates/vue/server/vite-errors.mjs +50 -0
- package/templates/vue/server.mjs +25 -44
- package/templates/vue/ui-app-config.ts +58 -0
- package/templates/vue/vite.config.ts +9 -2
package/README.md
CHANGED
|
@@ -355,6 +355,25 @@ directory added to or removed from `layers/` is the exception: Vite reads the
|
|
|
355
355
|
`#<layer>` aliases and the auto-imported directories at startup only, so the
|
|
356
356
|
dev server says it needs a restart.
|
|
357
357
|
|
|
358
|
+
### Icons
|
|
359
|
+
|
|
360
|
+
The browser never asks a third party for an icon, so a Content-Security-Policy
|
|
361
|
+
needs no exception for the Iconify API. Icons reach the page two ways:
|
|
362
|
+
|
|
363
|
+
- **Bundled.** Nuxt UI's client bundle holds every icon its scanner finds in
|
|
364
|
+
the workspace's Vue, Markdown and YAML files, the TypeScript of each layer's
|
|
365
|
+
`app/` directory — `app.config.ts` and its `ui.icons` mappings included — and
|
|
366
|
+
Nuxt UI's own defaults.
|
|
367
|
+
- **Served.** Any other icon, such as a page icon a backend module declares,
|
|
368
|
+
is fetched from the frontend server itself: `main.ts` points `@iconify/vue`
|
|
369
|
+
at `GET /api/_dms/icons/<prefix>.json?icons=<name>,…`, which answers in the
|
|
370
|
+
Iconify API format from the collections installed in the workspace
|
|
371
|
+
(`@iconify-json/<prefix>`, or `@iconify/json`). The workspace ships
|
|
372
|
+
`@iconify-json/lucide` and `@iconify-json/ph`; a module using another
|
|
373
|
+
collection depends on its `@iconify-json/*` package. An unknown collection
|
|
374
|
+
answers `404`, and a name outside Iconify's naming rule `400`. `ajs dms dev`
|
|
375
|
+
and `ajs dms start` run the same server, so both serve the route.
|
|
376
|
+
|
|
358
377
|
## Options
|
|
359
378
|
|
|
360
379
|
| Option | Environment | Purpose |
|
package/dist/config.js
CHANGED
|
@@ -36,11 +36,14 @@ exports.TEMPLATE_FILES = [
|
|
|
36
36
|
["main.ts", "main.ts"],
|
|
37
37
|
["app-runtime.ts", "app-runtime.ts"],
|
|
38
38
|
["network-error.ts", "network-error.ts"],
|
|
39
|
+
["ui-app-config.ts", "ui-app-config.ts"],
|
|
39
40
|
["ssr-renderer.ts", "ssr-renderer.ts"],
|
|
40
41
|
["frontend-module.ts", "frontend-module.ts"],
|
|
41
42
|
["globals.d.ts", "globals.d.ts"],
|
|
42
43
|
["compress-assets.mjs", "compress-assets.mjs"],
|
|
43
44
|
["head-order.mjs", "head-order.mjs"],
|
|
45
|
+
["icon-api.mjs", "icon-api.mjs"],
|
|
46
|
+
["icon-scan.mjs", "icon-scan.mjs"],
|
|
44
47
|
["email-renderer.ts", "email-renderer.ts"],
|
|
45
48
|
["email-runtime.ts", "email-runtime.ts"],
|
|
46
49
|
["email-locales.ts", "email-locales.ts"],
|
package/dist/fs-sync.js
CHANGED
|
@@ -151,8 +151,7 @@ function collectFiles(dir, baseDir = dir) {
|
|
|
151
151
|
const entries = (0, node_fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
152
152
|
for (const entry of entries) {
|
|
153
153
|
const fullPath = (0, node_path_1.join)(dir, entry.name);
|
|
154
|
-
const
|
|
155
|
-
const relPath = relative(baseDir, fullPath);
|
|
154
|
+
const relPath = (0, node_path_1.relative)(baseDir, fullPath);
|
|
156
155
|
if (entry.isDirectory()) {
|
|
157
156
|
files.push(...collectFiles(fullPath, baseDir));
|
|
158
157
|
}
|
|
@@ -179,12 +178,11 @@ function syncDirectories(src, dest) {
|
|
|
179
178
|
cleanEmptyDirs(dest);
|
|
180
179
|
}
|
|
181
180
|
function cleanEmptyDirs(dir) {
|
|
182
|
-
|
|
183
|
-
if (!(0, node_fs_1.existsSync)(dir) || !statSync(dir).isDirectory())
|
|
181
|
+
if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
|
|
184
182
|
return;
|
|
185
183
|
for (const entry of (0, node_fs_1.readdirSync)(dir)) {
|
|
186
184
|
const full = (0, node_path_1.join)(dir, entry);
|
|
187
|
-
if (statSync(full).isDirectory())
|
|
185
|
+
if ((0, node_fs_1.statSync)(full).isDirectory())
|
|
188
186
|
cleanEmptyDirs(full);
|
|
189
187
|
}
|
|
190
188
|
if ((0, node_fs_1.readdirSync)(dir).length === 0) {
|
package/dist/manifest.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ export declare class ManifestUnauthorizedError extends Error {
|
|
|
4
4
|
readonly status: number;
|
|
5
5
|
constructor(url: string, status: number);
|
|
6
6
|
}
|
|
7
|
+
export declare class ManifestRefusedError extends Error {
|
|
8
|
+
constructor(reason: string);
|
|
9
|
+
}
|
|
7
10
|
export declare function fetchManifest(backendUrl: string, clientUrl?: string, bootstrapSecret?: string): Promise<Manifest>;
|
|
8
11
|
export interface ManifestCacheEntry {
|
|
9
12
|
manifest: Manifest;
|
package/dist/manifest.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ManifestUnauthorizedError = void 0;
|
|
3
|
+
exports.ManifestRefusedError = exports.ManifestUnauthorizedError = void 0;
|
|
4
4
|
exports.fetchManifest = fetchManifest;
|
|
5
5
|
exports.readCachedManifest = readCachedManifest;
|
|
6
6
|
exports.resolveManifest = resolveManifest;
|
|
@@ -22,6 +22,13 @@ class ManifestUnauthorizedError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
exports.ManifestUnauthorizedError = ManifestUnauthorizedError;
|
|
25
|
+
class ManifestRefusedError extends Error {
|
|
26
|
+
constructor(reason) {
|
|
27
|
+
super(reason);
|
|
28
|
+
this.name = "ManifestRefusedError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.ManifestRefusedError = ManifestRefusedError;
|
|
25
32
|
async function fetchManifest(backendUrl, clientUrl, bootstrapSecret) {
|
|
26
33
|
const frontendUrl = new URL(`${backendUrl}/dms/frontend`);
|
|
27
34
|
frontendUrl.searchParams.set("renderer", "vue");
|
|
@@ -40,11 +47,11 @@ async function fetchManifest(backendUrl, clientUrl, bootstrapSecret) {
|
|
|
40
47
|
}
|
|
41
48
|
const manifest = (await response.json());
|
|
42
49
|
if (manifest.version !== workspace_1.FRONTEND_MANIFEST_VERSION) {
|
|
43
|
-
throw new
|
|
50
|
+
throw new ManifestRefusedError(`Unsupported frontend manifest version: ${String(manifest.version)}`);
|
|
44
51
|
}
|
|
45
52
|
const incompatible = manifest.modules.filter((module) => module.renderer?.name !== "vue" || module.renderer?.version !== "3");
|
|
46
53
|
if (incompatible.length) {
|
|
47
|
-
throw new
|
|
54
|
+
throw new ManifestRefusedError(`Frontend manifest contains incompatible renderer modules: ${incompatible.map((module) => module.name).join(", ")}`);
|
|
48
55
|
}
|
|
49
56
|
return { pack: manifest.archive, modules: manifest.modules };
|
|
50
57
|
}
|
|
@@ -80,6 +87,8 @@ async function resolveManifest(workspaceDir, backendUrl, offline, clientUrl, boo
|
|
|
80
87
|
catch (err) {
|
|
81
88
|
if (err instanceof ManifestUnauthorizedError)
|
|
82
89
|
throw err;
|
|
90
|
+
if (err instanceof ManifestRefusedError)
|
|
91
|
+
throw err;
|
|
83
92
|
const cached = readCachedManifest(workspaceDir);
|
|
84
93
|
if (!cached)
|
|
85
94
|
throw err;
|
package/dist/ports.js
CHANGED
|
@@ -19,7 +19,7 @@ async function reserveFreePort(preferred, range = exports.PORT_FALLBACK_RANGE) {
|
|
|
19
19
|
const server = await tryListen(port);
|
|
20
20
|
if (server) {
|
|
21
21
|
return {
|
|
22
|
-
port,
|
|
22
|
+
port: server.address().port,
|
|
23
23
|
release: () => new Promise((resolve) => {
|
|
24
24
|
server.close(() => resolve());
|
|
25
25
|
}),
|
package/dist/renderer-range.js
CHANGED
|
@@ -8,9 +8,14 @@ const semver_1 = __importDefault(require("semver"));
|
|
|
8
8
|
const layers_1 = require("./layers");
|
|
9
9
|
const OWN_RELEASE = require("../package.json");
|
|
10
10
|
const noticedModules = new Set();
|
|
11
|
+
function moduleLabel(layer) {
|
|
12
|
+
if (!layer.packageName)
|
|
13
|
+
return layer.path;
|
|
14
|
+
return `${layer.packageName} (${layer.path})`;
|
|
15
|
+
}
|
|
11
16
|
function declaredRange(layer, rendererName) {
|
|
12
17
|
return {
|
|
13
|
-
module: layer
|
|
18
|
+
module: moduleLabel(layer),
|
|
14
19
|
range: (0, layers_1.readLayerPackage)(layer.path)?.engines?.[rendererName],
|
|
15
20
|
};
|
|
16
21
|
}
|
|
@@ -74,7 +74,13 @@ const layers = roots.map((root) => ({
|
|
|
74
74
|
packageName: JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root, "package.json"), "utf8"))
|
|
75
75
|
.name,
|
|
76
76
|
}));
|
|
77
|
-
|
|
77
|
+
try {
|
|
78
|
+
(0, common_1.assertLayersSupportRenderer)(layers);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.error(`Error: ${error.message}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
78
84
|
(0, common_1.materializeLayers)(workspace, layers);
|
|
79
85
|
const registry = (0, common_1.createFrontendModuleRegistry)(workspace, layers);
|
|
80
86
|
registry.modules.forEach((module) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@antelopejs/dms-frontend",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antelope",
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@antelopejs/tooling-configs": ">=0.0.7 <1.0.0",
|
|
80
|
+
"@iconify/utils": "^3.1.0",
|
|
81
|
+
"@iconify/vue": "^5.0.1",
|
|
80
82
|
"@inertiajs/vue3": "^3.7.1",
|
|
81
83
|
"@types/figlet": "^1.7.0",
|
|
82
84
|
"@types/node": "^24.8.1",
|
|
@@ -6,7 +6,6 @@ import { en as uiEn, fr as uiFr } from "@nuxt/ui/locale";
|
|
|
6
6
|
import ui from "@nuxt/ui/vue-plugin";
|
|
7
7
|
import { useHead } from "@unhead/vue";
|
|
8
8
|
import type { VueHeadClient } from "@unhead/vue/types";
|
|
9
|
-
import { defu } from "defu";
|
|
10
9
|
import type { FetchOptions } from "ofetch";
|
|
11
10
|
import {
|
|
12
11
|
type App,
|
|
@@ -48,6 +47,7 @@ import {
|
|
|
48
47
|
syncLocaleMessages,
|
|
49
48
|
} from "./locales.generated";
|
|
50
49
|
import { showNetworkErrors } from "./network-error";
|
|
50
|
+
import { createUiAppConfigMerger } from "./ui-app-config";
|
|
51
51
|
|
|
52
52
|
export interface DmsInertiaSetupProps {
|
|
53
53
|
initialPage: unknown;
|
|
@@ -233,20 +233,10 @@ function renderDmsPersistentLayout(
|
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
|
|
237
|
-
* Nuxt UI keeps one app config for the whole process, and defu concatenates
|
|
238
|
-
* arrays: merged into it on every server render, the compound variants the DMS
|
|
239
|
-
* declares piled up, and every class list built from them grew with each
|
|
240
|
-
* request. Each config is merged once; a new one, after a reload, is merged
|
|
241
|
-
* again.
|
|
242
|
-
*/
|
|
243
|
-
const mergedUiAppConfigs = new WeakSet<object>();
|
|
236
|
+
const mergeUiAppConfig = createUiAppConfigMerger();
|
|
244
237
|
|
|
245
238
|
function mergeDmsAppConfig(): void {
|
|
246
|
-
|
|
247
|
-
if (mergedUiAppConfigs.has(uiAppConfig)) return;
|
|
248
|
-
Object.assign(uiAppConfig, defu(useDmsAppConfig(), uiAppConfig));
|
|
249
|
-
mergedUiAppConfigs.add(uiAppConfig);
|
|
239
|
+
mergeUiAppConfig(useAppConfig(), useDmsAppConfig());
|
|
250
240
|
}
|
|
251
241
|
|
|
252
242
|
export async function configureDmsApp(
|
|
@@ -906,9 +906,6 @@ export function useDmsRuntimeConfig(): DmsRuntimeConfig {
|
|
|
906
906
|
export function useDmsAppConfig(): DmsAppConfig {
|
|
907
907
|
return appConfig.value as DmsAppConfig;
|
|
908
908
|
}
|
|
909
|
-
export function configureDmsRuntime(config: DmsRuntimeConfig): void {
|
|
910
|
-
runtimeConfig.value = config;
|
|
911
|
-
}
|
|
912
909
|
export function defineAppConfig<T extends Record<string, unknown>>(
|
|
913
910
|
config: T,
|
|
914
911
|
): T {
|
|
@@ -934,14 +931,6 @@ export function defineDmsPageMeta(meta: Record<string, unknown>): void {
|
|
|
934
931
|
export function abortNavigation(): false {
|
|
935
932
|
return false;
|
|
936
933
|
}
|
|
937
|
-
export function addDmsMiddleware(
|
|
938
|
-
handler: DmsMiddleware,
|
|
939
|
-
name?: string,
|
|
940
|
-
isGlobal = false,
|
|
941
|
-
): void {
|
|
942
|
-
if (isGlobal) registry.middleware.push(handler);
|
|
943
|
-
else if (name) registry.namedMiddleware.set(name, handler);
|
|
944
|
-
}
|
|
945
934
|
export const useHead = useUnhead;
|
|
946
935
|
export const useSeoMeta = useUnheadSeoMeta;
|
|
947
936
|
export function useI18n(): DmsI18n {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the generated server answers the Iconify API, as
|
|
3
|
+
* `<ICON_API_PATH><prefix>.json?icons=a,b`. Under `/api/_dms/`, the prefix the
|
|
4
|
+
* server already answers itself, so it can never shadow a page or be proxied to
|
|
5
|
+
* the backend.
|
|
6
|
+
*/
|
|
7
|
+
export const ICON_API_PATH = "/api/_dms/icons/";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The `@iconify/vue` API provider serving every icon the client bundle lacks
|
|
11
|
+
* from `origin`, instead of `api.iconify.design` and its fallbacks.
|
|
12
|
+
*/
|
|
13
|
+
export function sameOriginIconProvider(origin) {
|
|
14
|
+
return { resources: [origin], path: ICON_API_PATH };
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
/** What `@nuxt/icon`'s `IconUsageScanner` reads when given no `globInclude`. */
|
|
4
|
+
const DEFAULT_SCAN_GLOBS = ["**/*.{vue,jsx,tsx,md,mdc,mdx,yml,yaml}"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The scripts of a frontend source root that may name an icon: its
|
|
8
|
+
* `app.config.ts` (the `ui.icons` mappings) and any icon a composable or a
|
|
9
|
+
* constant names in TypeScript. Limited to `app/`, so a module's tests,
|
|
10
|
+
* fixtures and tooling never reach the client bundle.
|
|
11
|
+
*/
|
|
12
|
+
const SOURCE_SCRIPT_GLOB = "app/**/*.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `globInclude` of the Nuxt UI client-bundle icon scan.
|
|
16
|
+
*
|
|
17
|
+
* The scanner walks the Vite root, with globs relative to it, and by default
|
|
18
|
+
* skips `.ts` files: every icon a layer names only in TypeScript was then
|
|
19
|
+
* fetched from the Iconify API at run time. Every frontend source root lives
|
|
20
|
+
* under the workspace (`frontend-modules/`, enforced when the registry is
|
|
21
|
+
* written), so a relative glob reaches it. Globs are POSIX on every platform.
|
|
22
|
+
*/
|
|
23
|
+
export function iconScanGlobs(viteRoot, sourceRoots) {
|
|
24
|
+
const sourceGlobs = sourceRoots.map((root) =>
|
|
25
|
+
[
|
|
26
|
+
...relative(viteRoot, root).split(sep).filter(Boolean),
|
|
27
|
+
SOURCE_SCRIPT_GLOB,
|
|
28
|
+
].join("/"),
|
|
29
|
+
);
|
|
30
|
+
return [...DEFAULT_SCAN_GLOBS, ...sourceGlobs];
|
|
31
|
+
}
|
package/templates/vue/main.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import "./dms-main.css";
|
|
2
|
+
import { addAPIProvider } from "@iconify/vue";
|
|
2
3
|
import { createInertiaApp } from "@inertiajs/vue3";
|
|
3
4
|
import { createHead } from "@unhead/vue/client";
|
|
4
5
|
import { createApp, createSSRApp, h } from "vue";
|
|
5
6
|
import { configureDmsApp, resolveDmsInertiaPage } from "./app-runtime";
|
|
6
7
|
import { setupFrontendModules } from "./frontend-module";
|
|
7
8
|
import { frontendModules } from "./frontend-modules.generated";
|
|
9
|
+
import { sameOriginIconProvider } from "./icon-api.mjs";
|
|
8
10
|
|
|
9
11
|
const DEV_STYLE_ATTRIBUTE = "data-dms-dev-style";
|
|
10
12
|
|
|
@@ -46,6 +48,9 @@ function markDmsReady(): void {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
if (import.meta.env.DEV) adoptViteDevStyles();
|
|
51
|
+
// Before any module or component can render an icon the client bundle lacks:
|
|
52
|
+
// the generated server answers those queries from the installed collections.
|
|
53
|
+
addAPIProvider("", sameOriginIconProvider(location.origin));
|
|
49
54
|
|
|
50
55
|
await setupFrontendModules(frontendModules);
|
|
51
56
|
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
"start": "node server.mjs"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
+
"@iconify-json/lucide": "^1.2.64",
|
|
13
|
+
"@iconify-json/ph": "^1.2.2",
|
|
14
|
+
"@iconify/utils": "^3.1.0",
|
|
15
|
+
"@iconify/vue": "^5.0.1",
|
|
12
16
|
"@inertiajs/vue3": "^3.7.0",
|
|
13
17
|
"@nuxt/ui": "4.10.0",
|
|
14
18
|
"@tailwindcss/typography": "^0.5.19",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import { pipeline } from "node:stream/promises";
|
|
3
|
+
import {
|
|
4
|
+
createBrotliCompress,
|
|
5
|
+
createGzip,
|
|
6
|
+
constants as zlibConstants,
|
|
7
|
+
} from "node:zlib";
|
|
8
|
+
|
|
9
|
+
const MINIMUM_COMPRESSION_BYTES = 1_024;
|
|
10
|
+
const DYNAMIC_BROTLI_QUALITY = 4;
|
|
11
|
+
|
|
12
|
+
function responseEncoder(request, content) {
|
|
13
|
+
if (Buffer.byteLength(content) < MINIMUM_COMPRESSION_BYTES) return undefined;
|
|
14
|
+
const accepted = request.headers["accept-encoding"] ?? "";
|
|
15
|
+
if (accepted.includes("br"))
|
|
16
|
+
return {
|
|
17
|
+
encoding: "br",
|
|
18
|
+
stream: createBrotliCompress({
|
|
19
|
+
params: {
|
|
20
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: DYNAMIC_BROTLI_QUALITY,
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
};
|
|
24
|
+
if (accepted.includes("gzip"))
|
|
25
|
+
return { encoding: "gzip", stream: createGzip() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Send a generated body, compressed when the client accepts it. */
|
|
29
|
+
export async function writeContent(
|
|
30
|
+
request,
|
|
31
|
+
response,
|
|
32
|
+
status,
|
|
33
|
+
headers,
|
|
34
|
+
content,
|
|
35
|
+
) {
|
|
36
|
+
const encoder = responseEncoder(request, content);
|
|
37
|
+
const vary = [headers.vary, "Accept-Encoding"].filter(Boolean).join(", ");
|
|
38
|
+
const encodedHeaders = { ...headers, vary };
|
|
39
|
+
if (!encoder) {
|
|
40
|
+
response.writeHead(status, encodedHeaders);
|
|
41
|
+
response.end(content);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
encodedHeaders["content-encoding"] = encoder.encoding;
|
|
45
|
+
response.writeHead(status, encodedHeaders);
|
|
46
|
+
await pipeline(Readable.from([content]), encoder.stream, response);
|
|
47
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getIcons } from "@iconify/utils";
|
|
6
|
+
import { ICON_API_PATH } from "../icon-api.mjs";
|
|
7
|
+
import { writeContent } from "./content.mjs";
|
|
8
|
+
|
|
9
|
+
const WORKSPACE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
10
|
+
const JSON_TYPE = "application/json";
|
|
11
|
+
const READ_METHODS = new Set(["GET", "HEAD"]);
|
|
12
|
+
// Iconify's own naming rule, for prefixes and icon names alike. It is also
|
|
13
|
+
// what keeps a request from naming anything but a collection file.
|
|
14
|
+
const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
|
+
const COLLECTION_FILE = /^([^/]+)\.json$/;
|
|
16
|
+
const ICON_NAME_SEPARATOR = ",";
|
|
17
|
+
// An icon's data only changes with the installed collection, which a
|
|
18
|
+
// deployment replaces; a week bounds how long an upgraded icon can lag.
|
|
19
|
+
const ICON_CACHE_CONTROL =
|
|
20
|
+
"public, max-age=604800, stale-while-revalidate=86400";
|
|
21
|
+
const ERROR_CACHE_CONTROL = "no-store";
|
|
22
|
+
const collections = new Map();
|
|
23
|
+
|
|
24
|
+
function collectionSpecifiers(prefix) {
|
|
25
|
+
return [
|
|
26
|
+
`@iconify-json/${prefix}/icons.json`,
|
|
27
|
+
`@iconify/json/json/${prefix}.json`,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveCollectionFile(root, prefix) {
|
|
32
|
+
const require = createRequire(join(root, "package.json"));
|
|
33
|
+
for (const specifier of collectionSpecifiers(prefix)) {
|
|
34
|
+
try {
|
|
35
|
+
return require.resolve(specifier);
|
|
36
|
+
} catch {
|
|
37
|
+
// Not installed under this name: try the next source.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadCollection(root, prefix) {
|
|
44
|
+
const file = resolveCollectionFile(root, prefix);
|
|
45
|
+
if (!file) return undefined;
|
|
46
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parsed once per collection: a collection file is several megabytes, and
|
|
51
|
+
* one page asks for icons of the same few collections many times.
|
|
52
|
+
*/
|
|
53
|
+
function collection(root, prefix) {
|
|
54
|
+
const key = join(root, prefix);
|
|
55
|
+
if (!collections.has(key)) {
|
|
56
|
+
const loading = loadCollection(root, prefix);
|
|
57
|
+
loading.catch(() => collections.delete(key));
|
|
58
|
+
collections.set(key, loading);
|
|
59
|
+
}
|
|
60
|
+
return collections.get(key);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function jsonResponse(status, cacheControl, payload) {
|
|
64
|
+
return {
|
|
65
|
+
status,
|
|
66
|
+
headers: { "content-type": JSON_TYPE, "cache-control": cacheControl },
|
|
67
|
+
body: JSON.stringify(payload),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorResponse(status, message) {
|
|
72
|
+
return jsonResponse(status, ERROR_CACHE_CONTROL, { message });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requestedIcons(url) {
|
|
76
|
+
const names = (url.searchParams.get("icons") ?? "")
|
|
77
|
+
.split(ICON_NAME_SEPARATOR)
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
if (!names.length || !names.every((name) => ICON_NAME.test(name)))
|
|
80
|
+
return undefined;
|
|
81
|
+
return [...new Set(names)];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether the generated server answers `pathname` with `iconResponse`. */
|
|
85
|
+
export function isIconRequest(pathname) {
|
|
86
|
+
return pathname.startsWith(ICON_API_PATH);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Answer an Iconify API icon query from the collections installed under
|
|
91
|
+
* `root`: the subset of `@iconify-json/<prefix>` (or `@iconify/json`) holding
|
|
92
|
+
* the requested icons, and a `not_found` list for the others, as
|
|
93
|
+
* `api.iconify.design` does. Returns the status, headers and body to send.
|
|
94
|
+
*/
|
|
95
|
+
export async function iconResponse(root, method, url) {
|
|
96
|
+
if (!READ_METHODS.has(method)) {
|
|
97
|
+
const response = errorResponse(405, "Method not allowed");
|
|
98
|
+
response.headers.allow = [...READ_METHODS].join(", ");
|
|
99
|
+
return response;
|
|
100
|
+
}
|
|
101
|
+
const prefix = url.pathname
|
|
102
|
+
.slice(ICON_API_PATH.length)
|
|
103
|
+
.match(COLLECTION_FILE)?.[1];
|
|
104
|
+
const names = requestedIcons(url);
|
|
105
|
+
if (!prefix || !ICON_NAME.test(prefix) || !names)
|
|
106
|
+
return errorResponse(400, "Invalid icon request");
|
|
107
|
+
const data = await collection(root, prefix);
|
|
108
|
+
if (!data) return errorResponse(404, "Unknown icon collection");
|
|
109
|
+
return jsonResponse(200, ICON_CACHE_CONTROL, getIcons(data, names, true));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Serve an Iconify API query from the collections installed in the workspace,
|
|
114
|
+
* the same way under `ajs dms dev` and `ajs dms start`: both run this server.
|
|
115
|
+
*/
|
|
116
|
+
export async function handleIcons(request, response) {
|
|
117
|
+
const url = new URL(request.url, "http://frontend.local");
|
|
118
|
+
const { status, headers, body } = await iconResponse(
|
|
119
|
+
WORKSPACE_ROOT,
|
|
120
|
+
request.method,
|
|
121
|
+
url,
|
|
122
|
+
);
|
|
123
|
+
return writeContent(request, response, status, headers, body);
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const INTERNAL_ERROR_STATUS = 500;
|
|
2
|
+
const ERROR_CONTENT_TYPE = "text/plain; charset=utf-8";
|
|
3
|
+
const VITE_ERROR = Symbol("dms.viteError");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In middleware mode, Vite's own error middleware logs an error, sends it to
|
|
7
|
+
* the client overlay, then passes the request on with no error: a module that
|
|
8
|
+
* fails to transform would fall through to page handling and answer 404. This
|
|
9
|
+
* plugin runs just before it, keeps the error on the request and hands it on,
|
|
10
|
+
* so Vite still logs it and shows the overlay.
|
|
11
|
+
*/
|
|
12
|
+
export function captureViteErrors() {
|
|
13
|
+
return {
|
|
14
|
+
name: "dms-capture-vite-errors",
|
|
15
|
+
apply: "serve",
|
|
16
|
+
configureServer(server) {
|
|
17
|
+
return () =>
|
|
18
|
+
server.middlewares.use((error, request, _response, next) => {
|
|
19
|
+
request[VITE_ERROR] = error;
|
|
20
|
+
next(error);
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hands a request to Vite's middlewares. Resolves `handled` once Vite answered
|
|
28
|
+
* it, `unhandled` when Vite passed it on, and `failed` with the error when a
|
|
29
|
+
* Vite middleware failed on it.
|
|
30
|
+
*/
|
|
31
|
+
export function runViteMiddlewares(devServer, request, response) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
devServer.middlewares(request, response, (error) => {
|
|
34
|
+
const failure = error ?? request[VITE_ERROR];
|
|
35
|
+
resolve(
|
|
36
|
+
failure
|
|
37
|
+
? { status: "failed", error: failure }
|
|
38
|
+
: { status: "unhandled" },
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
response.once("finish", () => resolve({ status: "handled" }));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Answers 500 with Vite's error message instead of rendering a page. */
|
|
46
|
+
export function writeViteError(response, error) {
|
|
47
|
+
response.statusCode = INTERNAL_ERROR_STATUS;
|
|
48
|
+
response.setHeader("content-type", ERROR_CONTENT_TYPE);
|
|
49
|
+
response.end(error?.message ?? String(error));
|
|
50
|
+
}
|
package/templates/vue/server.mjs
CHANGED
|
@@ -4,11 +4,6 @@ import { extname, join, resolve, sep } from "node:path";
|
|
|
4
4
|
import { Readable } from "node:stream";
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
8
|
-
createBrotliCompress,
|
|
9
|
-
createGzip,
|
|
10
|
-
constants as zlibConstants,
|
|
11
|
-
} from "node:zlib";
|
|
12
7
|
import { ofetch } from "ofetch";
|
|
13
8
|
import { RequestBodyError, UpstreamError } from "./server/auth/backend.mjs";
|
|
14
9
|
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./server/auth/client-ip.mjs";
|
|
@@ -25,10 +20,17 @@ import {
|
|
|
25
20
|
UNEXPECTED_ERROR_BODY,
|
|
26
21
|
} from "./server/backend-response.mjs";
|
|
27
22
|
import { productionHtmlTemplate } from "./server/client-manifest.mjs";
|
|
23
|
+
import { writeContent } from "./server/content.mjs";
|
|
24
|
+
import {
|
|
25
|
+
captureViteErrors,
|
|
26
|
+
runViteMiddlewares,
|
|
27
|
+
writeViteError,
|
|
28
|
+
} from "./server/vite-errors.mjs";
|
|
28
29
|
import { documentStyleTags } from "./server/dev-styles.mjs";
|
|
29
30
|
import { handleEmailRender, watchEmailBundle } from "./server/email.mjs";
|
|
30
31
|
import { HOMEPAGE } from "./server/homepage.mjs";
|
|
31
32
|
import { htmlTag } from "./server/html-tag.mjs";
|
|
33
|
+
import { handleIcons, isIconRequest } from "./server/icons.mjs";
|
|
32
34
|
import * as requestFailure from "./server/request-failure.mjs";
|
|
33
35
|
import { handleTester } from "./server/tester.mjs";
|
|
34
36
|
import {
|
|
@@ -65,10 +67,12 @@ const AUTH_SERVER_ROUTES = [
|
|
|
65
67
|
];
|
|
66
68
|
const HTML_RENDER_ROUTE = "/api/html/render";
|
|
67
69
|
const TESTER_ROUTE = /^\/api\/_dms\/tester\/?$/;
|
|
70
|
+
const LOCAL_ROUTES = [
|
|
71
|
+
[(pathname) => TESTER_ROUTE.test(pathname), handleTester],
|
|
72
|
+
[isIconRequest, handleIcons],
|
|
73
|
+
];
|
|
68
74
|
const AUTH_PAGE = "/auth";
|
|
69
75
|
const ONBOARDING_PAGE = "/onboarding";
|
|
70
|
-
const MINIMUM_COMPRESSION_BYTES = 1_024;
|
|
71
|
-
const DYNAMIC_BROTLI_QUALITY = 4;
|
|
72
76
|
const SOURCE_TEMPLATE_PATH = join(PROJECT_ROOT, "index.html");
|
|
73
77
|
const BUILT_SSR_RENDERER_PATH = join(PROJECT_ROOT, "dist/ssr/ssr-renderer.js");
|
|
74
78
|
// The SSR bundle keeps vue-i18n external, so Node loads its esm-bundler build,
|
|
@@ -89,7 +93,11 @@ async function developmentServer() {
|
|
|
89
93
|
const serverOptions = { middlewareMode: true };
|
|
90
94
|
if (frontendHttpServer)
|
|
91
95
|
serverOptions.hmr = { server: frontendHttpServer };
|
|
92
|
-
return createViteServer({
|
|
96
|
+
return createViteServer({
|
|
97
|
+
server: serverOptions,
|
|
98
|
+
appType: "custom",
|
|
99
|
+
plugins: [captureViteErrors()],
|
|
100
|
+
});
|
|
93
101
|
})
|
|
94
102
|
.then((server) => {
|
|
95
103
|
vite = server;
|
|
@@ -282,36 +290,6 @@ function serveAsset(pathname, request, response) {
|
|
|
282
290
|
return true;
|
|
283
291
|
}
|
|
284
292
|
|
|
285
|
-
function responseEncoder(request, content) {
|
|
286
|
-
if (Buffer.byteLength(content) < MINIMUM_COMPRESSION_BYTES) return undefined;
|
|
287
|
-
const accepted = request.headers["accept-encoding"] ?? "";
|
|
288
|
-
if (accepted.includes("br"))
|
|
289
|
-
return {
|
|
290
|
-
encoding: "br",
|
|
291
|
-
stream: createBrotliCompress({
|
|
292
|
-
params: {
|
|
293
|
-
[zlibConstants.BROTLI_PARAM_QUALITY]: DYNAMIC_BROTLI_QUALITY,
|
|
294
|
-
},
|
|
295
|
-
}),
|
|
296
|
-
};
|
|
297
|
-
if (accepted.includes("gzip"))
|
|
298
|
-
return { encoding: "gzip", stream: createGzip() };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async function writeContent(request, response, status, headers, content) {
|
|
302
|
-
const encoder = responseEncoder(request, content);
|
|
303
|
-
const vary = [headers.vary, "Accept-Encoding"].filter(Boolean).join(", ");
|
|
304
|
-
const encodedHeaders = { ...headers, vary };
|
|
305
|
-
if (!encoder) {
|
|
306
|
-
response.writeHead(status, encodedHeaders);
|
|
307
|
-
response.end(content);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
encodedHeaders["content-encoding"] = encoder.encoding;
|
|
311
|
-
response.writeHead(status, encodedHeaders);
|
|
312
|
-
await pipeline(Readable.from([content]), encoder.stream, response);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
293
|
async function ssrRenderer(devServer) {
|
|
316
294
|
if (devServer) return devServer.ssrLoadModule("/ssr-renderer.ts");
|
|
317
295
|
productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH);
|
|
@@ -416,10 +394,17 @@ async function writeBackendError(error, request, response) {
|
|
|
416
394
|
response.end(JSON.stringify(payload));
|
|
417
395
|
}
|
|
418
396
|
|
|
397
|
+
async function answeredByVite(devServer, request, response) {
|
|
398
|
+
const result = await runViteMiddlewares(devServer, request, response);
|
|
399
|
+
if (result.status === "failed") writeViteError(response, result.error);
|
|
400
|
+
return result.status !== "unhandled";
|
|
401
|
+
}
|
|
402
|
+
|
|
419
403
|
export async function handleRequest(request, response) {
|
|
420
404
|
const url = new URL(request.url, "http://frontend.local");
|
|
421
405
|
const pathname = url.pathname;
|
|
422
|
-
|
|
406
|
+
const localRoute = LOCAL_ROUTES.find(([matches]) => matches(pathname));
|
|
407
|
+
if (localRoute) return localRoute[1](request, response);
|
|
423
408
|
if (pathname === "/api/_auth/session")
|
|
424
409
|
return handleAuth(request, response, url);
|
|
425
410
|
if (request.method === "POST" && pathname === HTML_RENDER_ROUTE)
|
|
@@ -439,11 +424,7 @@ export async function handleRequest(request, response) {
|
|
|
439
424
|
return;
|
|
440
425
|
const devServer = await developmentServer();
|
|
441
426
|
if (devServer && pathname !== "/") {
|
|
442
|
-
|
|
443
|
-
devServer.middlewares(request, response, () => resolve(false));
|
|
444
|
-
response.once("finish", () => resolve(true));
|
|
445
|
-
});
|
|
446
|
-
if (handled) return;
|
|
427
|
+
if (await answeredByVite(devServer, request, response)) return;
|
|
447
428
|
}
|
|
448
429
|
if (!devServer && serveAsset(pathname, request, response)) return;
|
|
449
430
|
if (handleAssetVersionMismatch(request, response)) return;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defu } from "defu";
|
|
2
|
+
|
|
3
|
+
type PlainRecord = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
interface MergeState {
|
|
6
|
+
pristine: PlainRecord;
|
|
7
|
+
mergedFrom: object | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isPlainRecord(value: unknown): value is PlainRecord {
|
|
11
|
+
return (
|
|
12
|
+
typeof value === "object" &&
|
|
13
|
+
value !== null &&
|
|
14
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clonePlain<T>(value: T): T {
|
|
19
|
+
if (Array.isArray(value)) return value.map(clonePlain) as T;
|
|
20
|
+
if (!isPlainRecord(value)) return value;
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(value).map(([key, entry]) => [key, clonePlain(entry)]),
|
|
23
|
+
) as T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function replaceInPlace(target: PlainRecord, source: PlainRecord): void {
|
|
27
|
+
for (const key of Object.keys(target)) {
|
|
28
|
+
if (!(key in source)) delete target[key];
|
|
29
|
+
}
|
|
30
|
+
Object.assign(target, source);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Merges the DMS app config into Nuxt UI's, which lives for the whole process.
|
|
35
|
+
* Each DMS config is merged once, so arrays that defu concatenates never pile
|
|
36
|
+
* up across renders. A new DMS config, after a development reload, replaces
|
|
37
|
+
* 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.
|
|
39
|
+
*/
|
|
40
|
+
export function createUiAppConfigMerger(): (
|
|
41
|
+
uiAppConfig: PlainRecord,
|
|
42
|
+
dmsAppConfig: object,
|
|
43
|
+
) => void {
|
|
44
|
+
const states = new WeakMap<object, MergeState>();
|
|
45
|
+
return (uiAppConfig, dmsAppConfig) => {
|
|
46
|
+
let state = states.get(uiAppConfig);
|
|
47
|
+
if (state === undefined) {
|
|
48
|
+
state = { pristine: clonePlain({ ...uiAppConfig }), mergedFrom: null };
|
|
49
|
+
states.set(uiAppConfig, state);
|
|
50
|
+
}
|
|
51
|
+
if (state.mergedFrom === dmsAppConfig) return;
|
|
52
|
+
replaceInPlace(
|
|
53
|
+
uiAppConfig,
|
|
54
|
+
defu(clonePlain(dmsAppConfig), clonePlain(state.pristine)),
|
|
55
|
+
);
|
|
56
|
+
state.mergedFrom = dmsAppConfig;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -4,6 +4,7 @@ import ui from "@nuxt/ui/vite";
|
|
|
4
4
|
import vue from "@vitejs/plugin-vue";
|
|
5
5
|
import { defineConfig, normalizePath, type Plugin } from "vite";
|
|
6
6
|
import { orderHeadForFirstPaint } from "./head-order.mjs";
|
|
7
|
+
import { iconScanGlobs } from "./icon-scan.mjs";
|
|
7
8
|
|
|
8
9
|
interface FrontendModuleRegistryEntry {
|
|
9
10
|
id: string;
|
|
@@ -128,7 +129,11 @@ export default defineConfig({
|
|
|
128
129
|
vue(),
|
|
129
130
|
ui({
|
|
130
131
|
router: "inertia",
|
|
131
|
-
icon: {
|
|
132
|
+
icon: {
|
|
133
|
+
clientBundle: {
|
|
134
|
+
scan: { globInclude: iconScanGlobs(__dirname, frontendSourceRoots) },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
132
137
|
components: { dirs: [] },
|
|
133
138
|
autoImport: {
|
|
134
139
|
dirs: importDirectories,
|
|
@@ -183,7 +188,9 @@ export default defineConfig({
|
|
|
183
188
|
paintBeforeHydrate(),
|
|
184
189
|
],
|
|
185
190
|
resolve: {
|
|
186
|
-
|
|
191
|
+
// `@iconify/vue` keeps its API providers in module state: `main.ts` and
|
|
192
|
+
// Nuxt UI's `Icon` must share one copy for the same-origin provider to apply.
|
|
193
|
+
dedupe: ["vue", "reka-ui", "@nuxt/ui", "@vueuse/core", "@iconify/vue"],
|
|
187
194
|
alias: {
|
|
188
195
|
"#dms/frontend-module": resolve(__dirname, "frontend-module.ts"),
|
|
189
196
|
"#build/nuxt-icon-client-bundle": "virtual:nuxt-ui-icons",
|