320 lines
9.2 KiB
TypeScript
320 lines
9.2 KiB
TypeScript
// This file implements client-side bundling, mostly wrapping esbuild.
|
|
export async function bundleClientJavaScript(
|
|
referencedScripts: string[],
|
|
extraPublicScripts: string[],
|
|
incr: Incremental,
|
|
dev: boolean = false,
|
|
) {
|
|
const entryPoints = [
|
|
...new Set([
|
|
...referencedScripts.map((file) => path.resolve(hot.projectSrc, file)),
|
|
...extraPublicScripts,
|
|
]),
|
|
];
|
|
if (entryPoints.length === 0) return;
|
|
const invalidFiles = entryPoints
|
|
.filter((file) => !file.match(/\.client\.[tj]sx?/));
|
|
if (invalidFiles.length > 0) {
|
|
const cwd = process.cwd();
|
|
throw new Error(
|
|
"All client-side scripts should be named like '.client.ts'. Exceptions: \n" +
|
|
invalidFiles.map((x) => path.join(cwd, x)).join("\n"),
|
|
);
|
|
}
|
|
|
|
const clientPlugins: esbuild.Plugin[] = [
|
|
projectRelativeResolution(),
|
|
markoViaBuildCache(incr),
|
|
];
|
|
|
|
const bundle = await esbuild.build({
|
|
assetNames: "/asset/[hash]",
|
|
bundle: true,
|
|
chunkNames: "/js/c.[hash]",
|
|
entryNames: "/js/[name]",
|
|
entryPoints,
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
jsxDev: dev,
|
|
jsxImportSource: "#ssr",
|
|
logLevel: "silent",
|
|
metafile: true,
|
|
minify: !dev,
|
|
outdir: "out!",
|
|
plugins: clientPlugins,
|
|
write: false,
|
|
define: {
|
|
"ASSERT": "console.assert",
|
|
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
|
},
|
|
}).catch((err: any) => {
|
|
err.message = `Client ${err.message}`;
|
|
throw err;
|
|
});
|
|
if (bundle.errors.length || bundle.warnings.length) {
|
|
throw new AggregateError(
|
|
bundle.errors.concat(bundle.warnings),
|
|
"JS bundle failed",
|
|
);
|
|
}
|
|
const publicScriptRoutes = extraPublicScripts.map((file) =>
|
|
"/js/" +
|
|
path.relative(hot.projectSrc, file).replaceAll("\\", "/").replace(
|
|
/\.client\.[tj]sx?/,
|
|
".js",
|
|
)
|
|
);
|
|
const { metafile, outputFiles } = bundle;
|
|
const promises: Promise<void>[] = [];
|
|
for (const file of outputFiles) {
|
|
const { text } = file;
|
|
let route = file.path.replace(/^.*!/, "").replaceAll("\\", "/");
|
|
const { inputs } = UNWRAP(metafile.outputs["out!" + route]);
|
|
const sources = Object.keys(inputs)
|
|
.filter((x) => !x.startsWith("<define:"));
|
|
|
|
// Register non-chunks as script entries.
|
|
const chunk = route.startsWith("/js/c.");
|
|
if (!chunk) {
|
|
const key = hot.getScriptId(path.resolve(sources[sources.length - 1]));
|
|
route = "/js/" + key.replace(/\.client\.tsx?/, ".js");
|
|
incr.put({
|
|
sources,
|
|
kind: "script",
|
|
key,
|
|
value: text,
|
|
});
|
|
}
|
|
// Register chunks and public scripts as assets.
|
|
if (chunk || publicScriptRoutes.includes(route)) {
|
|
promises.push(incr.putAsset({
|
|
sources,
|
|
key: route,
|
|
body: text,
|
|
}));
|
|
}
|
|
}
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
export type ServerPlatform = "node" | "passthru";
|
|
export async function bundleServerJavaScript(
|
|
incr: Incremental,
|
|
platform: ServerPlatform = "node",
|
|
) {
|
|
if (incr.hasArtifact("backendBundle", platform)) return;
|
|
|
|
// Comment
|
|
const magicWord = "C_" + crypto.randomUUID().replaceAll("-", "_");
|
|
|
|
const viewSource = [
|
|
...Array.from(
|
|
incr.out.viewMetadata,
|
|
([, view], i) => `import * as view${i} from ${JSON.stringify(view.file)}`,
|
|
),
|
|
`const styles = ${magicWord}[-2]`,
|
|
`export const scripts = ${magicWord}[-1]`,
|
|
"export const views = {",
|
|
...Array.from(incr.out.viewMetadata, ([key, view], i) =>
|
|
[
|
|
` ${JSON.stringify(key)}: {`,
|
|
` component: view${i}.default,`,
|
|
// ` meta: ${
|
|
// view.staticMeta ? JSON.stringify(view.staticMeta) : `view${i}.meta`
|
|
// },`,
|
|
` meta: view${i}.meta,`,
|
|
` layout: ${view.hasLayout ? `view${i}.layout?.default` : "null"},`,
|
|
` inlineCss: styles[${magicWord}[${i}]]`,
|
|
` },`,
|
|
].join("\n")),
|
|
"}",
|
|
].join("\n");
|
|
|
|
// -- plugins --
|
|
const serverPlugins: esbuild.Plugin[] = [
|
|
virtualFiles({
|
|
"$views": viewSource,
|
|
}),
|
|
projectRelativeResolution(),
|
|
markoViaBuildCache(incr),
|
|
{
|
|
name: "replace client references",
|
|
setup(b) {
|
|
b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
|
|
contents:
|
|
hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,
|
|
loader: path.extname(file).slice(1) as esbuild.Loader,
|
|
}));
|
|
},
|
|
},
|
|
{
|
|
name: "mark css external",
|
|
setup(b) {
|
|
b.onResolve(
|
|
{ filter: /\.css$/ },
|
|
() => ({ path: ".", namespace: "dropped" }),
|
|
);
|
|
b.onLoad(
|
|
{ filter: /./, namespace: "dropped" },
|
|
() => ({ contents: "" }),
|
|
);
|
|
},
|
|
},
|
|
];
|
|
const pkg = await fs.readJson("package.json") as {
|
|
dependencies: Record<string, string>;
|
|
};
|
|
const { metafile, outputFiles } = await esbuild.build({
|
|
bundle: true,
|
|
chunkNames: "c.[hash]",
|
|
entryNames: "server",
|
|
entryPoints: [
|
|
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
|
|
],
|
|
platform: "node",
|
|
format: "esm",
|
|
minify: false,
|
|
outdir: "out!",
|
|
plugins: serverPlugins,
|
|
splitting: true,
|
|
logLevel: "silent",
|
|
write: false,
|
|
metafile: true,
|
|
jsx: "automatic",
|
|
jsxImportSource: "#ssr",
|
|
jsxDev: false,
|
|
define: {
|
|
MIME_INLINE_DATA: JSON.stringify(mime.rawEntriesText),
|
|
},
|
|
external: Object.keys(pkg.dependencies)
|
|
.filter((x) => !x.startsWith("@paperclover")),
|
|
});
|
|
|
|
const files: Record<string, Buffer> = {};
|
|
let fileWithMagicWord: string | null = null;
|
|
for (const output of outputFiles) {
|
|
const basename = output.path.replace(/^.*?!/, "");
|
|
const key = "out!" + basename.replaceAll("\\", "/");
|
|
// If this contains the generated "$views" file, then
|
|
// mark this file as the one for replacement. Because
|
|
// `splitting` is `true`, esbuild will not emit this
|
|
// file in more than one chunk.
|
|
if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
|
|
fileWithMagicWord = basename;
|
|
}
|
|
files[basename] = Buffer.from(output.contents);
|
|
}
|
|
incr.put({
|
|
kind: "backendBundle",
|
|
key: platform,
|
|
value: {
|
|
magicWord,
|
|
files,
|
|
fileWithMagicWord,
|
|
},
|
|
sources: Object.keys(metafile.inputs).filter((x) =>
|
|
!x.includes("<define:") &&
|
|
!x.startsWith("vfs:") &&
|
|
!x.startsWith("dropped:") &&
|
|
!x.includes("node_modules")
|
|
),
|
|
});
|
|
}
|
|
|
|
export async function finalizeServerJavaScript(
|
|
incr: Incremental,
|
|
platform: ServerPlatform,
|
|
) {
|
|
if (incr.hasArtifact("backendReplace", platform)) return;
|
|
const {
|
|
files,
|
|
fileWithMagicWord,
|
|
magicWord,
|
|
} = UNWRAP(incr.getArtifact("backendBundle", platform));
|
|
|
|
if (!fileWithMagicWord) return;
|
|
|
|
// Only the reachable resources need to be inserted into the bundle.
|
|
const viewScriptsList = new Set(
|
|
Array.from(incr.out.viewMetadata.values())
|
|
.flatMap((view) => view.clientRefs),
|
|
);
|
|
const viewStyleKeys = Array.from(incr.out.viewMetadata.values())
|
|
.map((view) => css.styleKey(view.cssImports, view.theme));
|
|
const viewCssBundles = viewStyleKeys
|
|
.map((key) => UNWRAP(incr.out.style.get(key), "Style key: " + key));
|
|
|
|
// Deduplicate styles
|
|
const styleList = Array.from(new Set(viewCssBundles));
|
|
|
|
// Replace the magic word
|
|
let text = files[fileWithMagicWord].toString("utf-8");
|
|
text = text.replace(
|
|
new RegExp(magicWord + "\\[(-?\\d+)\\]", "gs"),
|
|
(_, i) => {
|
|
i = Number(i);
|
|
// Inline the styling data
|
|
if (i === -2) {
|
|
return JSON.stringify(styleList.map((cssText) => cssText));
|
|
}
|
|
// Inline the script data
|
|
if (i === -1) {
|
|
return JSON.stringify(Object.fromEntries(incr.out.script));
|
|
}
|
|
// Reference an index into `styleList`
|
|
return `${styleList.indexOf(viewCssBundles[i])}`;
|
|
},
|
|
);
|
|
|
|
incr.put({
|
|
kind: "backendReplace",
|
|
key: platform,
|
|
sources: [
|
|
// Backend input code (includes view code)
|
|
...incr.sourcesFor("backendBundle", platform),
|
|
// Script
|
|
...Array.from(viewScriptsList)
|
|
.flatMap((key) => incr.sourcesFor("script", hot.getScriptId(key))),
|
|
// Style
|
|
...viewStyleKeys.flatMap((key) => incr.sourcesFor("style", key)),
|
|
],
|
|
value: Buffer.from(text),
|
|
});
|
|
}
|
|
|
|
function markoViaBuildCache(incr: Incremental): esbuild.Plugin {
|
|
return {
|
|
name: "marko via build cache",
|
|
setup(b) {
|
|
b.onLoad(
|
|
{ filter: /\.marko$/ },
|
|
async ({ path: file }) => {
|
|
const key = path.relative(hot.projectRoot, file)
|
|
.replaceAll("\\", "/");
|
|
const cacheEntry = incr.out.serverMarko.get(key);
|
|
if (!cacheEntry) {
|
|
if (!fs.existsSync(file)) {
|
|
console.log(`File does not exist: ${file}`);
|
|
}
|
|
throw new Error("Marko file not in cache: " + file);
|
|
}
|
|
return ({
|
|
loader: "ts",
|
|
contents: cacheEntry.src,
|
|
resolveDir: path.dirname(file),
|
|
});
|
|
},
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
import * as esbuild from "esbuild";
|
|
import * as path from "node:path";
|
|
import process from "node:process";
|
|
import * as hot from "./hot.ts";
|
|
import { projectRelativeResolution, virtualFiles } from "./esbuild-support.ts";
|
|
import { Incremental } from "./incremental.ts";
|
|
import * as css from "./css.ts";
|
|
import * as fs from "#sitegen/fs";
|
|
import * as mime from "#sitegen/mime";
|