mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 02:44:57 +08:00
1092 lines
42 KiB
TypeScript
1092 lines
42 KiB
TypeScript
/**
|
|
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
|
|
* Licensed under the Apache License, Version 2.0.
|
|
*/
|
|
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import browserslist from "browserslist";
|
|
import { exec } from "child_process";
|
|
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
import {
|
|
browserslistToTargets,
|
|
transform as lightningcssTransform,
|
|
} from "lightningcss";
|
|
import { basename, dirname, join, relative, resolve, sep } from "path";
|
|
import { minify as terserMinify } from "terser";
|
|
import { promisify } from "util";
|
|
import {
|
|
defineConfig,
|
|
loadEnv,
|
|
Plugin,
|
|
ResolvedConfig,
|
|
ViteDevServer,
|
|
} from "vite";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
const CURRENT_DIR = process.cwd();
|
|
const PROJECT_ROOT = resolve(CURRENT_DIR, "..");
|
|
const BUILD_OUTPUT = resolve(PROJECT_ROOT, "htdocs/luci-static");
|
|
|
|
const LIGHTNINGCSS_TARGETS = browserslistToTargets(
|
|
browserslist("last 4 versions, Firefox ESR, not dead"),
|
|
);
|
|
|
|
function createLuciJsCompressPlugin(): Plugin {
|
|
let outDir: string;
|
|
|
|
return {
|
|
name: "luci-js-compress",
|
|
apply: "build",
|
|
configResolved(config: ResolvedConfig) {
|
|
outDir = config.build.outDir;
|
|
},
|
|
async generateBundle() {
|
|
const srcDir = resolve(CURRENT_DIR, "src/resource");
|
|
const jsFiles = (await readdir(srcDir, { recursive: true })).filter((f) =>
|
|
f.endsWith(".js"),
|
|
);
|
|
await Promise.all(
|
|
jsFiles.map(async (relPath) => {
|
|
const normalized = relPath.replace(/\\/g, "/");
|
|
try {
|
|
const sourceCode = await readFile(join(srcDir, relPath), "utf-8");
|
|
const compressed = await terserMinify(sourceCode, {
|
|
parse: { bare_returns: true },
|
|
/* LuCI dependency declarations are string directives. Keep
|
|
them while enabling normal compression and local mangling. */
|
|
compress: { directives: false, passes: 2 },
|
|
mangle: true,
|
|
format: { comments: false, beautify: false },
|
|
});
|
|
// patches/* are payloads of the on-demand patches mechanism and
|
|
// ship next to the CSS patches (media dir), not under resources/.
|
|
const stem = normalized.startsWith("patches/")
|
|
? normalized.slice("patches/".length, -".js".length)
|
|
: null;
|
|
const outputPaths = stem
|
|
? [stem, ...(PATCH_ALIASES[stem] ?? [])].map((p) =>
|
|
join(outDir, "aurora", "patches", `${p}.js`),
|
|
)
|
|
: [join(outDir, "resources", normalized)];
|
|
for (const outputPath of outputPaths) {
|
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
await writeFile(
|
|
outputPath,
|
|
compressed.code || sourceCode,
|
|
"utf-8",
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(
|
|
`${tag("JS Compress")} src/resource/${normalized}: ${error?.message}`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/* Duplicate built CSS patches under their PATCH_ALIASES names (JS aliases are
|
|
handled inside the compress plugin). Runs post-bundle because the sources
|
|
are Rollup entries. */
|
|
function createPatchAliasPlugin(): Plugin {
|
|
return {
|
|
name: "patch-alias",
|
|
apply: "build",
|
|
enforce: "post",
|
|
async closeBundle() {
|
|
for (const [stem, aliases] of Object.entries(PATCH_ALIASES)) {
|
|
const source = resolve(BUILD_OUTPUT, `aurora/patches/${stem}.css`);
|
|
if (!existsSync(source)) continue;
|
|
const css = await readFile(source, "utf-8");
|
|
for (const alias of aliases)
|
|
await writeFile(
|
|
resolve(BUILD_OUTPUT, `aurora/patches/${alias}.css`),
|
|
css,
|
|
"utf-8",
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function removeMacOsMetadata(dir: string): Promise<void> {
|
|
if (!existsSync(dir)) return;
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) await removeMacOsMetadata(path);
|
|
else if (entry.name === ".DS_Store") await rm(path, { force: true });
|
|
}
|
|
}
|
|
|
|
/* login.css imports the full shared token sheet but the login page consumes
|
|
only a fraction of it. Prune every custom-property declaration (and --tw-*
|
|
@property registration) that no var() reference can reach, following
|
|
declaration-value chains (--a: var(--b) keeps --b alive). --login-bg and
|
|
--login-bg-lqip stay consumed-without-declaration by design: header.ut
|
|
injects them from UCI at render time, so pruning never touches consumers. */
|
|
function createLoginCssPrunePlugin(): Plugin {
|
|
return {
|
|
name: "login-css-prune",
|
|
apply: "build",
|
|
enforce: "post",
|
|
async closeBundle() {
|
|
const path = resolve(BUILD_OUTPUT, "aurora/login.css");
|
|
if (!existsSync(path)) return;
|
|
const css = await readFile(path, "utf-8");
|
|
|
|
// Roots are vars consumed by normal declarations; custom-property
|
|
// declarations only contribute edges to the reachability walk.
|
|
const VALUE = `(?:"[^"]*"|'[^']*'|[^;{}"'])*`;
|
|
const roots = new Set<string>();
|
|
const edges = new Map<string, Set<string>>();
|
|
for (const [, name, value] of css.matchAll(
|
|
new RegExp(`(?<=[{;])(--[\\w-]+|[a-zA-Z-]+):(${VALUE})`, "g"),
|
|
)) {
|
|
const refs = [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(
|
|
(m) => m[1],
|
|
);
|
|
if (!name.startsWith("--")) refs.forEach((r) => roots.add(r));
|
|
else {
|
|
const deps = edges.get(name) ?? new Set<string>();
|
|
refs.forEach((r) => deps.add(r));
|
|
edges.set(name, deps);
|
|
}
|
|
}
|
|
const keep = new Set(roots);
|
|
const stack = [...roots];
|
|
while (stack.length) {
|
|
for (const dep of edges.get(stack.pop()!) ?? []) {
|
|
if (!keep.has(dep)) {
|
|
keep.add(dep);
|
|
stack.push(dep);
|
|
}
|
|
}
|
|
}
|
|
|
|
const pruned = css
|
|
.replace(
|
|
new RegExp(`(?<=[{;])(--[\\w-]+):${VALUE};?`, "g"),
|
|
(decl, name) => (keep.has(name) ? decl : ""),
|
|
)
|
|
.replace(/@property\s+(--[\w-]+)\{[^{}]*\}/g, (rule, name) =>
|
|
keep.has(name) ? rule : "",
|
|
);
|
|
|
|
// Re-minify with the shared targets: validates the edited syntax and
|
|
// drops any rule the pruning emptied out.
|
|
const { code } = lightningcssTransform({
|
|
filename: "login.css",
|
|
code: Buffer.from(pruned),
|
|
minify: true,
|
|
targets: LIGHTNINGCSS_TARGETS,
|
|
});
|
|
await writeFile(path, code);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createPackageHygienePlugin(): Plugin {
|
|
return {
|
|
name: "package-hygiene",
|
|
apply: "build",
|
|
enforce: "post",
|
|
async closeBundle() {
|
|
await Promise.all([
|
|
removeMacOsMetadata(resolve(PROJECT_ROOT, "htdocs")),
|
|
removeMacOsMetadata(resolve(PROJECT_ROOT, "ucode")),
|
|
]);
|
|
},
|
|
};
|
|
}
|
|
|
|
// On-demand third-party patches: serve src/media/patches/<page>.css at
|
|
// /luci-static/aurora/patches/<page>.css in dev. Without this, header.ut's patch
|
|
// <link> falls through to the OpenWrt proxy (404 / stale router asset) and patch
|
|
// edits don't trigger HMR. Matched per request so new patch files work without
|
|
// a dev-server restart.
|
|
const PATCH_PUBLIC_PREFIX = "/luci-static/aurora/patches/";
|
|
const PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/media/patches");
|
|
|
|
// JS payloads of the same on-demand mechanism: src/resource/patches/<page>.js
|
|
// builds to aurora/patches/<page>.js so header.ut's single lsdir() discovers
|
|
// CSS and JS patches together.
|
|
const JS_PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/resource/patches");
|
|
|
|
// A built payload (CSS or JS) can serve several pages via aliases, keyed by
|
|
// source stem. Prefix matching is per-page, and naming one file after a
|
|
// shorter shared prefix (e.g. `admin-status`) would load it on every status
|
|
// subpage — including the busiest overview page — so the built output is
|
|
// duplicated under each alias instead. Two identical CSS *entries* would not
|
|
// work either: Rollup deduplicates same-content assets into a single file.
|
|
// Currently empty: the log viewer needs only admin-status-logs — every
|
|
// supported release (23.05/24.10/master) mounts both log pages under
|
|
// admin/status/logs/*, so the prefix covers System Log, Kernel Log and the
|
|
// bare /logs alias with one name.
|
|
const PATCH_ALIASES: Record<string, string[]> = {};
|
|
const patchAliasSource = (stem: string): string | undefined =>
|
|
Object.entries(PATCH_ALIASES).find(([, aliases]) =>
|
|
aliases.includes(stem),
|
|
)?.[0];
|
|
|
|
// Theme assets (public/aurora/**: images, fonts): serve the copy in this
|
|
// checkout at /luci-static/aurora/<path>. Without this, header.ut's logo,
|
|
// favicon, webmanifest and font references fall through to the OpenWrt proxy
|
|
// and resolve against the *installed* package, so an icon or font added here
|
|
// 404s until the package is rebuilt and reinstalled. Only files that exist
|
|
// locally are rewritten — everything else still proxies through.
|
|
const ASSET_PUBLIC_PREFIX = "/luci-static/aurora/";
|
|
const ASSET_SRC_DIR = resolve(CURRENT_DIR, "public/aurora");
|
|
|
|
// Resolves a request path under public/aurora/, or null when it escapes the
|
|
// directory (../) or names something that is not a file there.
|
|
function localAsset(relPath: string): string | null {
|
|
let decoded: string;
|
|
try {
|
|
decoded = decodeURIComponent(relPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const file = resolve(ASSET_SRC_DIR, decoded);
|
|
if (!file.startsWith(ASSET_SRC_DIR + sep)) return null;
|
|
|
|
return existsSync(file) && statSync(file).isFile() ? decoded : null;
|
|
}
|
|
|
|
function createLocalServePlugin(): Plugin {
|
|
const cssRoutes: Record<string, string> = {
|
|
"/luci-static/aurora/main.css": "/src/media/main.css",
|
|
"/luci-static/aurora/login.css": "/src/media/login.css",
|
|
};
|
|
const jsRoutes: Record<string, string> = {
|
|
"/luci-static/resources/view/aurora/sysauth.js":
|
|
"src/resource/view/aurora/sysauth.js",
|
|
"/luci-static/resources/menu-aurora.js": "src/resource/menu-aurora.js",
|
|
"/luci-static/resources/router-aurora.js": "src/resource/router-aurora.js",
|
|
};
|
|
|
|
// Any theme CSS (entries, partials, patches) or served JS change must force
|
|
// a full reload: proxied LuCI pages link /luci-static/... URLs, so Vite's
|
|
// granular css-update never matches them and would silently do nothing.
|
|
const MEDIA_SRC_DIR = resolve(CURRENT_DIR, "src/media").replace(/\\/g, "/");
|
|
const reloadJsFiles = new Set(
|
|
Object.values(jsRoutes).map((src) =>
|
|
resolve(CURRENT_DIR, src).replace(/\\/g, "/"),
|
|
),
|
|
);
|
|
|
|
return {
|
|
name: "local-serve-plugin",
|
|
apply: "serve",
|
|
enforce: "pre",
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url) return next();
|
|
const [pathname, search] = req.url.split("?");
|
|
const cssTarget = cssRoutes[pathname];
|
|
if (cssTarget) {
|
|
req.url = cssTarget + (search ? `?${search}` : "");
|
|
return next();
|
|
}
|
|
if (
|
|
pathname.startsWith(PATCH_PUBLIC_PREFIX) &&
|
|
pathname.endsWith(".css")
|
|
) {
|
|
// Aliases resolve to their shared source, mirroring the build-time
|
|
// duplication.
|
|
const stem = basename(pathname, ".css");
|
|
const source = existsSync(join(PATCH_SRC_DIR, `${stem}.css`))
|
|
? stem
|
|
: patchAliasSource(stem);
|
|
if (source && existsSync(join(PATCH_SRC_DIR, `${source}.css`))) {
|
|
req.url =
|
|
`/src/media/patches/${source}.css` + (search ? `?${search}` : "");
|
|
return next();
|
|
}
|
|
}
|
|
if (
|
|
pathname.startsWith(PATCH_PUBLIC_PREFIX) &&
|
|
pathname.endsWith(".js")
|
|
) {
|
|
const stem = basename(pathname, ".js");
|
|
const source = existsSync(join(JS_PATCH_SRC_DIR, `${stem}.js`))
|
|
? stem
|
|
: patchAliasSource(stem);
|
|
if (source && existsSync(join(JS_PATCH_SRC_DIR, `${source}.js`))) {
|
|
try {
|
|
const code = await readFile(
|
|
join(JS_PATCH_SRC_DIR, `${source}.js`),
|
|
"utf-8",
|
|
);
|
|
res.setHeader("Content-Type", "text/javascript");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.statusCode = 200;
|
|
res.end(code);
|
|
return;
|
|
} catch (err: any) {
|
|
console.error(
|
|
`${tag("Serve")} ${pathname} → cannot read patches/${source}.js: ${err?.message ?? err}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (pathname.startsWith(ASSET_PUBLIC_PREFIX)) {
|
|
const asset = localAsset(pathname.slice(ASSET_PUBLIC_PREFIX.length));
|
|
if (asset) {
|
|
req.url = `/aurora/${asset}` + (search ? `?${search}` : "");
|
|
return next();
|
|
}
|
|
}
|
|
const jsPath = jsRoutes[pathname];
|
|
if (jsPath) {
|
|
try {
|
|
const code = await readFile(resolve(CURRENT_DIR, jsPath), "utf-8");
|
|
res.setHeader("Content-Type", "text/javascript");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.statusCode = 200;
|
|
res.end(code);
|
|
return;
|
|
} catch (err: any) {
|
|
console.error(
|
|
`${tag("Serve")} ${pathname} → cannot read ${jsPath}: ${err?.message ?? err}`,
|
|
);
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
},
|
|
handleHotUpdate({ file, server }) {
|
|
const nf = file.replace(/\\/g, "/");
|
|
const isThemeCss =
|
|
nf.startsWith(MEDIA_SRC_DIR + "/") && nf.endsWith(".css");
|
|
const isJsPatch =
|
|
nf.startsWith(JS_PATCH_SRC_DIR.replace(/\\/g, "/") + "/") &&
|
|
nf.endsWith(".js");
|
|
if (isThemeCss || isJsPatch || reloadJsFiles.has(nf)) {
|
|
console.log(
|
|
`${tag("Serve")} ${relative(CURRENT_DIR, file)} → full reload`,
|
|
);
|
|
server.ws.send({ type: "full-reload", path: "*" });
|
|
return [];
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// Mock pages: render saved LuCI page snapshots against the live theme, so a
|
|
// third-party app's page can be styled without the app (or a device) installed.
|
|
// Snapshots live in .dev/mocks/*.html and are served at /mocks/<name>.html
|
|
// with the Vite HMR client injected, so theme edits (main.css, components,
|
|
// patches/*.css, served JS) trigger the existing full-reload in handleHotUpdate.
|
|
// Any third-party asset a snapshot needs (the app's own css/js, e.g.
|
|
// qmodem-next.css) goes under .dev/mocks/static/ mirroring its /luci-static/…
|
|
// URL and is served as-is (no HMR). Both served snapshots and proxied device
|
|
// pages get scripts/mock-bar.client.js — a floating bar that lists the
|
|
// snapshots, opens the one matching the open page, captures the open device
|
|
// page (POST /mocks/__save), and inside a snapshot takes over its LuCI links.
|
|
// See "Mock Pages" in .dev/docs/DEVELOPMENT.md.
|
|
const MOCK_ROUTE = "/mocks";
|
|
const MOCKS_DIR = resolve(CURRENT_DIR, "mocks");
|
|
const MOCKS_STATIC_DIR = join(MOCKS_DIR, "static");
|
|
const MOCK_BAR_CLIENT = resolve(CURRENT_DIR, "scripts/mock-bar.client.js");
|
|
// A captured LuCI page is tens of KB — anything past this is not a page.
|
|
const MOCK_SAVE_LIMIT = 20 * 1024 * 1024;
|
|
|
|
const MOCK_MIME: Record<string, string> = {
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".mjs": "text/javascript; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".html": "text/html; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".map": "application/json; charset=utf-8",
|
|
};
|
|
|
|
function mockContentType(file: string): string {
|
|
const ext = file.slice(file.lastIndexOf("."));
|
|
return MOCK_MIME[ext] ?? "application/octet-stream";
|
|
}
|
|
|
|
// document.documentElement.outerHTML (the documented manual capture) drops the
|
|
// doctype; without one the browser renders the mock in quirks mode and layout
|
|
// no longer matches the real page. Every serve and save path funnels through
|
|
// this.
|
|
function ensureDoctype(html: string): string {
|
|
return /^\s*<!doctype/i.test(html) ? html : `<!doctype html>\n${html}`;
|
|
}
|
|
|
|
const escapeHtml = (s: string): string =>
|
|
s.replace(
|
|
/[&<>"]/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!,
|
|
);
|
|
|
|
// Inject the Vite HMR client so a served snapshot joins the WS channel and
|
|
// receives handleHotUpdate's full-reload broadcast on any theme source change.
|
|
function injectHmrClient(html: string): string {
|
|
if (html.includes("/@vite/client")) return html;
|
|
const tag = `\n <script type="module" src="/@vite/client"></script>\n`;
|
|
return html.includes("</head>")
|
|
? html.replace("</head>", `${tag} </head>`)
|
|
: tag + html;
|
|
}
|
|
|
|
// A snapshot is static DOM. Left alone, LuCI's runtime (luci.js + the inline
|
|
// `new LuCI(...)` bootstrap) boots, polls the backend, gets 403 (no session)
|
|
// and pops the "Session expired" modal. Neutralise it for mock rendering:
|
|
// strip the scripts that phone home (luci.js/cbi.js/xhr.js and /cgi-bin/
|
|
// endpoints) and pre-define a no-op `L`/`LuCI`/`XHR` stub so the remaining
|
|
// inline bootstraps (`new LuCI(...)`, `L.require(...)`, legacy `XHR.poll(...)`)
|
|
// run harmlessly. Theme CSS/JS and the theme's own inline scripts (dark mode,
|
|
// toolbar) are untouched; framework-dependent theme JS such as menu-aurora
|
|
// simply no-ops — the captured DOM is already fully rendered, so a static
|
|
// review still looks right.
|
|
const MOCK_RUNTIME_GUARD = `<script>
|
|
(function () {
|
|
var stub = new Proxy(function () {}, {
|
|
get: function () { return stub; },
|
|
apply: function () { return stub; },
|
|
construct: function () { return stub; },
|
|
});
|
|
window.L = stub;
|
|
window.LuCI = stub;
|
|
window.XHR = stub;
|
|
})();
|
|
</script>`;
|
|
|
|
function neutralizeLuciRuntime(html: string): string {
|
|
const stripped = html.replace(
|
|
/<script\b[^>]*\bsrc="[^"]*(?:\/luci-static\/resources\/(?:luci|cbi|xhr)\.js|\/cgi-bin\/)[^"]*"[^>]*>\s*<\/script>/gi,
|
|
"",
|
|
);
|
|
return stripped.includes("</head>")
|
|
? stripped.replace(
|
|
/<head\b[^>]*>/i,
|
|
(m) => `${m}\n ${MOCK_RUNTIME_GUARD}`,
|
|
)
|
|
: MOCK_RUNTIME_GUARD + stripped;
|
|
}
|
|
|
|
// Snapshot inventory for the index and for in-mock navigation. A snapshot's
|
|
// identity is its <body data-page> (filenames are free), so each file is
|
|
// scanned for it once and re-read only when its mtime changes.
|
|
interface MockEntry {
|
|
file: string;
|
|
page: string | null;
|
|
mtimeMs: number;
|
|
}
|
|
const mockMeta = new Map<string, { mtimeMs: number; page: string | null }>();
|
|
|
|
function listMocks(): MockEntry[] {
|
|
if (!existsSync(MOCKS_DIR)) return [];
|
|
return readdirSync(MOCKS_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isFile() && d.name.endsWith(".html"))
|
|
.map((d) => {
|
|
const path = join(MOCKS_DIR, d.name);
|
|
const mtimeMs = statSync(path).mtimeMs;
|
|
let meta = mockMeta.get(d.name);
|
|
if (!meta || meta.mtimeMs !== mtimeMs) {
|
|
const page = readFileSync(path, "utf-8").match(
|
|
/<body\b[^>]*\bdata-page="([^"]*)"/i,
|
|
)?.[1];
|
|
meta = { mtimeMs, page: page || null };
|
|
mockMeta.set(d.name, meta);
|
|
}
|
|
return { file: d.name, page: meta.page, mtimeMs };
|
|
})
|
|
.sort((a, b) => a.file.localeCompare(b.file));
|
|
}
|
|
|
|
// Handing the snapshot list to the page is what makes the bar work in both
|
|
// contexts: inside a snapshot it also drives the link takeover, on a device
|
|
// page it tells the bar which snapshots exist (`current` is null there — a
|
|
// live page is not one of them). data-aurora-mock marks every tag this server
|
|
// injects so a capture can strip them back out.
|
|
function injectMockBar(html: string, current: string | null): string {
|
|
const payload = JSON.stringify({
|
|
current,
|
|
mocks: listMocks().map(({ file, page }) => ({ file, page })),
|
|
}).replace(/</g, "\\u003c");
|
|
const tags =
|
|
`<script data-aurora-mock>window.__AURORA_MOCKS__ = ${payload};</script>\n` +
|
|
` <script data-aurora-mock defer src="${MOCK_ROUTE}/__bar.js"></script>\n `;
|
|
return html.includes("</head>")
|
|
? html.replace("</head>", `${tags}</head>`)
|
|
: html + tags;
|
|
}
|
|
|
|
function readRequestBody(req: IncomingMessage, limit: number): Promise<string> {
|
|
return new Promise((resolveBody, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
req.on("data", (chunk: Buffer) => {
|
|
size += chunk.length;
|
|
if (size > limit) {
|
|
reject(Object.assign(new Error("body too large"), { statusCode: 413 }));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf-8")));
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function relTime(mtimeMs: number): string {
|
|
const mins = Math.round((Date.now() - mtimeMs) / 60000);
|
|
if (mins < 60) return `${Math.max(mins, 0)}m`;
|
|
if (mins < 60 * 24) return `${Math.round(mins / 60)}h`;
|
|
return `${Math.round(mins / (60 * 24))}d`;
|
|
}
|
|
|
|
function renderMockIndex(entries: MockEntry[]): string {
|
|
const rows = entries.length
|
|
? entries
|
|
.map((e) => {
|
|
const meta = [e.page ?? "no data-page", relTime(e.mtimeMs)]
|
|
.map(escapeHtml)
|
|
.join(" · ");
|
|
return (
|
|
`<li><a href="${MOCK_ROUTE}/${encodeURIComponent(e.file)}">` +
|
|
`${escapeHtml(e.file)}<span class="meta">${meta}</span></a></li>`
|
|
);
|
|
})
|
|
.join("\n ")
|
|
: `<li class="empty">No snapshots yet — open a page through the dev proxy and press <code>Alt/Option+Shift+S</code>, or drop a page's HTML into <code>.dev/mocks/</code></li>`;
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Aurora mock pages</title>
|
|
<style>
|
|
body{font:15px/1.6 system-ui,-apple-system,sans-serif;max-width:680px;margin:48px auto;padding:0 20px;color:#1a1a1a}
|
|
h1{font-size:20px;margin:0 0 4px}
|
|
p.sub{color:#888;font-size:13px;margin:0 0 24px}
|
|
ul{list-style:none;padding:0;margin:0}
|
|
li{margin:8px 0}
|
|
li a{display:flex;justify-content:space-between;align-items:baseline;gap:16px;padding:8px 14px;border:1px solid #d0d0d0;border-radius:8px;text-decoration:none;color:#0a7d4b;font-weight:500}
|
|
li a .meta{color:#888;font-size:12px;font-weight:400;white-space:nowrap}
|
|
li a:hover{background:#f5f5f5}
|
|
li.empty{color:#888}
|
|
code{background:#f0f0f0;padding:1px 6px;border-radius:4px;font-size:13px}
|
|
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}li a{border-color:#333;color:#4ade80}li a:hover{background:#1c1c1c}code{background:#222}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Aurora mock pages</h1>
|
|
<p class="sub">Saved snapshots served against the live theme. Edit theme CSS/JS → the open page hot-reloads; links between captured pages navigate in place.</p>
|
|
<ul>
|
|
${rows}
|
|
</ul>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function createMockPlugin(): Plugin {
|
|
const sendJson = (res: ServerResponse, status: number, body: unknown) => {
|
|
res.statusCode = status;
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
// Each missing mock asset gets one terminal hint, not one per reload.
|
|
const mockMissLogged = new Set<string>();
|
|
|
|
return {
|
|
name: "mock-pages-plugin",
|
|
apply: "serve",
|
|
enforce: "pre",
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (!req.url) return next();
|
|
const [pathname] = req.url.split("?");
|
|
const inMocks =
|
|
pathname === MOCK_ROUTE || pathname.startsWith(`${MOCK_ROUTE}/`);
|
|
if (!inMocks && !pathname.startsWith("/luci-static/")) return next();
|
|
|
|
// Every branch answers inside this try: a stray request (malformed
|
|
// %-encoding, a directory named *.html, …) must degrade to an error
|
|
// response — thrown, it becomes an unhandled rejection and takes the
|
|
// whole dev server down with it.
|
|
try {
|
|
// Dev-helper client script (kept as a real file for editability).
|
|
if (pathname === `${MOCK_ROUTE}/__bar.js`) {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end(await readFile(MOCK_BAR_CLIENT, "utf-8"));
|
|
return;
|
|
}
|
|
|
|
// Capture endpoint, written to by mock-bar.client.js from proxied
|
|
// device pages. The custom header doubles as a CORS gate: a
|
|
// foreign origin can't send it without a preflight this server never
|
|
// approves, so drive-by cross-site POSTs are rejected.
|
|
if (pathname === `${MOCK_ROUTE}/__save`) {
|
|
if (req.method !== "POST")
|
|
return sendJson(res, 405, { error: "POST only" });
|
|
if (req.headers["x-aurora-capture"] !== "1")
|
|
return sendJson(res, 403, { error: "missing capture header" });
|
|
const html = (await readRequestBody(req, MOCK_SAVE_LIMIT))
|
|
// The live DOM carries the dev-only tags this server injected;
|
|
// a snapshot must stay a clean capture of the real page. The
|
|
// snapshot list in particular must not be baked in — it is
|
|
// re-injected, current, on every serve.
|
|
.replace(
|
|
/<script\b[^>]*\bdata-aurora-mock\b[^>]*>[\s\S]*?<\/script>\s*/gi,
|
|
"",
|
|
)
|
|
.replace(
|
|
/<script[^>]*\bsrc="\/@vite\/client"[^>]*>\s*<\/script>\s*/gi,
|
|
"",
|
|
);
|
|
const page =
|
|
html.match(/<body\b[^>]*\bdata-page="([^"]*)"/i)?.[1] ?? "";
|
|
const safe = page.replace(/[^\w.-]/g, "-").replace(/^\.+/, "");
|
|
const name = `${safe.slice(0, 120) || `snapshot-${Date.now()}`}.html`;
|
|
await mkdir(MOCKS_DIR, { recursive: true });
|
|
await writeFile(
|
|
join(MOCKS_DIR, name),
|
|
ensureDoctype(html),
|
|
"utf-8",
|
|
);
|
|
console.log(
|
|
`${tag("Mocks")} captured ${name}${page ? ` (${page})` : ""}`,
|
|
);
|
|
return sendJson(res, 200, { file: name, page: page || null });
|
|
}
|
|
|
|
// Index of available snapshots.
|
|
if (pathname === MOCK_ROUTE || pathname === `${MOCK_ROUTE}/`) {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end(renderMockIndex(listMocks()));
|
|
return;
|
|
}
|
|
|
|
// A single snapshot: LuCI runtime neutralised, HMR client + mock bar
|
|
// injected, doctype restored.
|
|
if (inMocks && pathname.endsWith(".html")) {
|
|
const name = basename(decodeURIComponent(pathname));
|
|
const file = resolve(MOCKS_DIR, name);
|
|
if (
|
|
!file.startsWith(MOCKS_DIR + sep) ||
|
|
!existsSync(file) ||
|
|
!statSync(file).isFile()
|
|
) {
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.end(
|
|
`<!doctype html><meta charset="utf-8"><p>Mock not found: ${escapeHtml(name)} — <a href="${MOCK_ROUTE}/">back to index</a>`,
|
|
);
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
res.setHeader("Cache-Control", "no-store");
|
|
const snapshot = await readFile(file, "utf-8");
|
|
res.end(
|
|
ensureDoctype(
|
|
injectMockBar(
|
|
injectHmrClient(neutralizeLuciRuntime(snapshot)),
|
|
name,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Third-party static drop-ins the snapshot references (the app's own
|
|
// css/js) — reached only when local-serve didn't already claim the
|
|
// path.
|
|
if (pathname.startsWith("/luci-static/")) {
|
|
const file = resolve(
|
|
MOCKS_STATIC_DIR,
|
|
decodeURIComponent(pathname.slice(1)),
|
|
);
|
|
if (
|
|
file.startsWith(MOCKS_STATIC_DIR + sep) &&
|
|
existsSync(file) &&
|
|
statSync(file).isFile()
|
|
) {
|
|
res.statusCode = 200;
|
|
res.setHeader("Content-Type", mockContentType(file));
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end(await readFile(file));
|
|
return;
|
|
}
|
|
// A miss requested BY a mock page must fail fast, not fall
|
|
// through to the device proxy: with no reachable router the
|
|
// request just hangs, pinning the tab's load event for minutes
|
|
// (device-only assets like a custom logo hit this). Real proxied
|
|
// pages keep their normal fallthrough.
|
|
const referer = req.headers.referer ?? "";
|
|
if (
|
|
new URL(referer, "http://_").pathname.startsWith(`${MOCK_ROUTE}/`)
|
|
) {
|
|
if (!mockMissLogged.has(pathname)) {
|
|
mockMissLogged.add(pathname);
|
|
console.log(
|
|
`${tag("Mocks")} miss ${pathname} → 404 (mirror it at .dev/mocks/static${pathname} to serve it)`,
|
|
);
|
|
}
|
|
res.statusCode = 404;
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Whatever else reaches here on /luci-static/ is destined for the
|
|
// device proxy. Static files answer in milliseconds on a real
|
|
// router, so bound the wait: CSS-initiated requests (nav icons)
|
|
// carry the stylesheet's URL as referer and dodge the mock 404
|
|
// above — with no reachable device they would otherwise pin the
|
|
// tab's load event for minutes. Vite's proxy error handler
|
|
// tolerates an already-ended response, and /cgi-bin is untouched
|
|
// (dynamic endpoints can be legitimately slow).
|
|
const deadline = setTimeout(() => {
|
|
if (!res.headersSent && res.writable) {
|
|
console.log(
|
|
`${tag("Proxy")} ${pathname} → 504 after 5s (router unreachable?)`,
|
|
);
|
|
res.statusCode = 504;
|
|
res.end();
|
|
}
|
|
}, 5000);
|
|
res.on("close", () => clearTimeout(deadline));
|
|
}
|
|
|
|
next();
|
|
} catch (err: any) {
|
|
const status =
|
|
err?.statusCode ?? (err instanceof URIError ? 400 : 500);
|
|
console.error(`${tag("Mocks")} ${req.url}: ${err?.message ?? err}`);
|
|
if (res.headersSent) {
|
|
res.destroy();
|
|
return;
|
|
}
|
|
if (pathname === `${MOCK_ROUTE}/__save`) {
|
|
sendJson(res, status, { error: String(err?.message ?? err) });
|
|
} else {
|
|
res.statusCode = status;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end(status === 400 ? "Bad request" : "Mock route error");
|
|
}
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
const UT_TEMPLATE_DIR = resolve(PROJECT_ROOT, "ucode/template/themes/aurora");
|
|
const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora";
|
|
|
|
// Key selection is ssh's own job: ssh-agent or a Host block in ~/.ssh/config.
|
|
// ConnectTimeout applies to every ssh call: template pushes gate /cgi-bin page
|
|
// loads, so an unreachable device must fail fast instead of hanging them.
|
|
const SSH_ARGS =
|
|
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5";
|
|
|
|
function tag(name: string): string {
|
|
return `${new Date().toLocaleTimeString("en-US")} [${name}]`;
|
|
}
|
|
|
|
const utTag = () => tag("UT Sync");
|
|
|
|
const parseHost = (sshHost: string): string => sshHost.split("@").pop()!;
|
|
|
|
function reportSshError(err: any, sshHost: string): void {
|
|
const host = parseHost(sshHost);
|
|
const stderr = err?.stderr || err?.message || "";
|
|
|
|
if (
|
|
stderr.includes("Host key verification failed") ||
|
|
stderr.includes("REMOTE HOST IDENTIFICATION HAS CHANGED")
|
|
) {
|
|
console.error(`\n${utTag()} SSH host key mismatch for ${host}.`);
|
|
console.error(`${utTag()} The device may have been reflashed. Run:\n`);
|
|
console.error(` ssh-keygen -R ${host}\n`);
|
|
} else if (
|
|
stderr.includes("Permission denied") ||
|
|
stderr.includes("Authentication failed")
|
|
) {
|
|
console.error(`\n${utTag()} SSH authentication failed for ${sshHost}.`);
|
|
console.error(
|
|
`${utTag()} Run \`pnpm setup:router\` to configure passwordless login.\n`,
|
|
);
|
|
} else if (
|
|
stderr.includes("Connection refused") ||
|
|
stderr.includes("Connection timed out") ||
|
|
stderr.includes("No route to host")
|
|
) {
|
|
console.error(
|
|
`\n${utTag()} Cannot reach ${host}. Check that the device is online and SSH is enabled.\n`,
|
|
);
|
|
} else {
|
|
console.error(`\n${utTag()} SSH connection failed: ${stderr}\n`);
|
|
}
|
|
}
|
|
|
|
async function checkSshConnection(sshHost: string): Promise<boolean> {
|
|
try {
|
|
await execAsync(`ssh ${SSH_ARGS} "${sshHost}" echo ok`);
|
|
console.log(`${utTag()} SSH connection to ${sshHost} verified.`);
|
|
return true;
|
|
} catch (err: any) {
|
|
reportSshError(err, sshHost);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createUtSyncPlugin(sshHost: string): Plugin {
|
|
let dirty = false;
|
|
let flushing: Promise<void> | null = null;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
// Filenames whose change events triggered the pending sync — the push itself
|
|
// is always the whole directory, this only makes the log say what changed.
|
|
const pending = new Set<string>();
|
|
|
|
// The templates are tiny, so every sync just pushes the whole directory in
|
|
// one tarball streamed over ssh stdin — OpenSSH 9+ scp defaults to the SFTP
|
|
// protocol, which Dropbear on OpenWrt does not ship a server for. Note the
|
|
// tar extract only adds/overwrites: deleting or renaming a local .ut leaves
|
|
// the old file on the device until a reinstall or manual cleanup.
|
|
const pushAll = () =>
|
|
execAsync(
|
|
`tar -C "${UT_TEMPLATE_DIR}" -cf - . | ssh ${SSH_ARGS} "${sshHost}" "mkdir -p '${UT_REMOTE_DIR}' && tar -xf - -C '${UT_REMOTE_DIR}'"`,
|
|
);
|
|
|
|
const flush = (server: ViteDevServer): Promise<void> => {
|
|
if (!flushing) {
|
|
flushing = (async () => {
|
|
while (dirty) {
|
|
dirty = false;
|
|
const files = [...pending];
|
|
pending.clear();
|
|
const started = Date.now();
|
|
try {
|
|
await pushAll();
|
|
const what = files.length ? files.join(", ") : "all templates";
|
|
console.log(
|
|
`${utTag()} ${what} → ${sshHost} (${Date.now() - started}ms)`,
|
|
);
|
|
server.ws.send({ type: "full-reload", path: "*" });
|
|
} catch (err: any) {
|
|
files.forEach((f) => pending.add(f));
|
|
reportSshError(err, sshHost);
|
|
break;
|
|
}
|
|
}
|
|
flushing = null;
|
|
})();
|
|
}
|
|
return flushing;
|
|
};
|
|
|
|
const markDirty = (server: ViteDevServer) => {
|
|
dirty = true;
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => flush(server), 150);
|
|
};
|
|
|
|
return {
|
|
name: "ut-sync-plugin",
|
|
apply: "serve",
|
|
configureServer(server) {
|
|
console.log(
|
|
`${utTag()} ${relative(PROJECT_ROOT, UT_TEMPLATE_DIR)}/*.ut → ${sshHost}:${UT_REMOTE_DIR}`,
|
|
);
|
|
|
|
// Full push on startup so edits made while the server was down (or a
|
|
// freshly flashed device) can't leave the router stale.
|
|
checkSshConnection(sshHost).then((ok) => {
|
|
if (ok) markDirty(server);
|
|
});
|
|
|
|
server.watcher.add(UT_TEMPLATE_DIR);
|
|
const onTemplateEvent = (file: string) => {
|
|
if (file.startsWith(UT_TEMPLATE_DIR) && file.endsWith(".ut")) {
|
|
pending.add(basename(file));
|
|
markDirty(server);
|
|
}
|
|
};
|
|
server.watcher.on("add", onTemplateEvent);
|
|
server.watcher.on("change", onTemplateEvent);
|
|
|
|
// Hold page loads until pending template pushes land, so a proxied
|
|
// render never uses a stale template.
|
|
server.middlewares.use((req, res, next) => {
|
|
if (!req.url?.startsWith("/cgi-bin") || (!dirty && !flushing)) {
|
|
return next();
|
|
}
|
|
if (timer) clearTimeout(timer);
|
|
console.log(`${utTag()} Holding ${req.url} until templates sync…`);
|
|
flush(server).then(
|
|
() => next(),
|
|
() => next(),
|
|
);
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function createRedirectPlugin(): Plugin {
|
|
return {
|
|
name: "redirect-plugin",
|
|
apply: "serve",
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url === "/" || req.url === "/index.html") {
|
|
res.writeHead(302, { Location: "/cgi-bin/luci" });
|
|
res.end();
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig(({ mode }) => {
|
|
const env = loadEnv(mode, CURRENT_DIR);
|
|
// VITE_OPENWRT_HOST is just the router address — a bare IP/hostname like
|
|
// 192.168.1.1 (host:port and http:// URL forms also work). The web proxy
|
|
// target and the .ut-sync ssh target are both derived from it; ssh key
|
|
// selection etc. belongs in ~/.ssh/config, not here.
|
|
const OPENWRT_RAW = env.VITE_OPENWRT_HOST || "192.168.1.1";
|
|
const OPENWRT = new URL(
|
|
/^https?:\/\//.test(OPENWRT_RAW) ? OPENWRT_RAW : `http://${OPENWRT_RAW}`,
|
|
);
|
|
const OPENWRT_URL = OPENWRT.origin;
|
|
const OPENWRT_SSH_HOST = `root@${OPENWRT.hostname}`;
|
|
const DEV_HOST = env.VITE_DEV_HOST || "127.0.0.1";
|
|
const DEV_PORT = Number(env.VITE_DEV_PORT) || 5173;
|
|
|
|
return {
|
|
plugins: [
|
|
tailwindcss(),
|
|
createRedirectPlugin(),
|
|
createLocalServePlugin(),
|
|
createMockPlugin(),
|
|
createUtSyncPlugin(OPENWRT_SSH_HOST),
|
|
createLuciJsCompressPlugin(),
|
|
createLoginCssPrunePlugin(),
|
|
createPatchAliasPlugin(),
|
|
createPackageHygienePlugin(),
|
|
],
|
|
css: {
|
|
lightningcss: {
|
|
targets: LIGHTNINGCSS_TARGETS,
|
|
},
|
|
},
|
|
build: {
|
|
outDir: BUILD_OUTPUT,
|
|
emptyOutDir: false,
|
|
cssMinify: "lightningcss",
|
|
rollupOptions: {
|
|
input: {
|
|
main: resolve(CURRENT_DIR, "src/media/main.css"),
|
|
login: resolve(CURRENT_DIR, "src/media/login.css"),
|
|
// On-demand third-party patches: one entry per page, output to
|
|
// aurora/patches/<page>.css (the `patches/` key prefix lands them there
|
|
// via assetFileNames below). header.ut links the matching one per page.
|
|
// `_`-prefixed files are shared partials @imported by entries, not
|
|
// entries themselves (they'd otherwise ship as never-matching patches).
|
|
...Object.fromEntries(
|
|
(existsSync(PATCH_SRC_DIR) ? readdirSync(PATCH_SRC_DIR) : [])
|
|
.filter((f) => f.endsWith(".css") && !f.startsWith("_"))
|
|
.map((f) => [
|
|
`patches/${f.slice(0, -4)}`,
|
|
join(PATCH_SRC_DIR, f),
|
|
]),
|
|
),
|
|
},
|
|
output: { assetFileNames: "aurora/[name].[ext]" },
|
|
},
|
|
},
|
|
server: {
|
|
host: DEV_HOST,
|
|
port: DEV_PORT,
|
|
proxy: {
|
|
"/luci-static": {
|
|
target: OPENWRT_URL,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
},
|
|
"/cgi-bin": {
|
|
target: OPENWRT_URL,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
// We write every response ourselves in `proxyRes` below, so the Vite
|
|
// client can be injected into proxied LuCI HTML.
|
|
selfHandleResponse: true,
|
|
configure: (proxy) => {
|
|
// Force an uncompressed upstream response: the HTML injection below
|
|
// treats the body as UTF-8 text and would corrupt a gzipped payload.
|
|
proxy.on("proxyReq", (proxyReq) => {
|
|
proxyReq.removeHeader("accept-encoding");
|
|
});
|
|
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
const status = proxyRes.statusCode ?? 200;
|
|
const ct = proxyRes.headers["content-type"] || "";
|
|
if (!ct.includes("text/html")) {
|
|
res.writeHead(status, proxyRes.headers);
|
|
proxyRes.pipe(res);
|
|
return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
proxyRes.on("data", (c: Buffer) => chunks.push(c));
|
|
proxyRes.on("end", () => {
|
|
let html = Buffer.concat(chunks).toString("utf-8");
|
|
const client = `<script type="module" src="/@vite/client"></script>`;
|
|
if (
|
|
html.includes("</head>") &&
|
|
!html.includes("/@vite/client")
|
|
) {
|
|
html = html.replace("</head>", `${client}\n\t</head>`);
|
|
}
|
|
// Real device pages also get the mock bar, so the snapshot
|
|
// workflow is one click away instead of a URL to remember:
|
|
// it lists what .dev/mocks/ holds, opens this page's own
|
|
// snapshot when there is one, and captures the open page
|
|
// (same as Alt/Option+Shift+S). `current` is null — a live
|
|
// page is not itself a snapshot.
|
|
if (html.includes("</head>") && !html.includes("/__bar.js")) {
|
|
html = injectMockBar(html, null);
|
|
}
|
|
const { "transfer-encoding": _, ...headers } = proxyRes.headers;
|
|
res.writeHead(status, {
|
|
...headers,
|
|
"content-length": Buffer.byteLength(html),
|
|
});
|
|
res.end(html);
|
|
});
|
|
});
|
|
},
|
|
},
|
|
},
|
|
headers: { "Cache-Control": "no-store" },
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
"@": resolve(CURRENT_DIR, "src"),
|
|
"@assets": resolve(CURRENT_DIR, "src/assets"),
|
|
},
|
|
},
|
|
};
|
|
});
|