@pablozaiden/webapp 0.5.1 → 0.5.3
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/package.json +1 -1
- package/src/build/build-binary.ts +106 -16
- package/src/server/create-web-app-server.ts +69 -17
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export type BunCompileTarget =
|
|
5
5
|
| "bun-linux-x64"
|
|
@@ -13,6 +13,9 @@ export interface BuildWebAppBinaryOptions {
|
|
|
13
13
|
outfile: string;
|
|
14
14
|
target?: BunCompileTarget;
|
|
15
15
|
define?: Record<string, string>;
|
|
16
|
+
web?: {
|
|
17
|
+
entry?: string;
|
|
18
|
+
};
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function getBunCompileTargetFromArgs(argv = Bun.argv): BunCompileTarget | undefined {
|
|
@@ -22,21 +25,108 @@ export function getBunCompileTargetFromArgs(argv = Bun.argv): BunCompileTarget |
|
|
|
22
25
|
|
|
23
26
|
export async function buildWebAppBinary(options: BuildWebAppBinaryOptions): Promise<void> {
|
|
24
27
|
mkdirSync(dirname(options.outfile), { recursive: true });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
const packageRoot = findPackageRoot(dirname(resolve(options.entrypoint)));
|
|
29
|
+
const buildId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
30
|
+
const cacheDir = resolve(packageRoot, ".cache", "webapp-build", buildId);
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
33
|
+
const webEntry = resolve(dirname(resolve(options.entrypoint)), options.web?.entry ?? "./web/main.tsx");
|
|
34
|
+
const rendererEntry = resolve(cacheDir, "webapp-renderer-prelude.ts");
|
|
35
|
+
const clientEntry = resolve(cacheDir, "webapp-client-entry.ts");
|
|
36
|
+
const browserOutDir = resolve(cacheDir, "browser");
|
|
37
|
+
writeFileSync(rendererEntry, `import { createRoot } from "react-dom/client";
|
|
38
|
+
import { configureWebAppRenderer } from "@pablozaiden/webapp/web";
|
|
39
|
+
|
|
40
|
+
configureWebAppRenderer(createRoot);
|
|
41
|
+
`);
|
|
42
|
+
writeFileSync(clientEntry, `import ${JSON.stringify(webEntry)};
|
|
43
|
+
`);
|
|
44
|
+
const browserBuild = await Bun.build({
|
|
45
|
+
entrypoints: [rendererEntry, clientEntry],
|
|
46
|
+
outdir: browserOutDir,
|
|
47
|
+
target: "browser",
|
|
48
|
+
format: "esm",
|
|
49
|
+
splitting: true,
|
|
50
|
+
publicPath: "/webapp-compiled/",
|
|
51
|
+
minify: true,
|
|
52
|
+
sourcemap: "external",
|
|
53
|
+
define: options.define,
|
|
54
|
+
});
|
|
55
|
+
if (!browserBuild.success) {
|
|
56
|
+
for (const log of browserBuild.logs) {
|
|
57
|
+
console.error(log);
|
|
58
|
+
}
|
|
59
|
+
throw new Error("Browser build failed");
|
|
60
|
+
}
|
|
61
|
+
const assets = browserBuild.outputs
|
|
62
|
+
.filter((output) => extname(output.path).toLowerCase() !== ".map")
|
|
63
|
+
.map((output) => {
|
|
64
|
+
const ext = extname(output.path).toLowerCase();
|
|
65
|
+
const fileName = basename(output.path);
|
|
66
|
+
const publicPath = `/webapp-compiled/${basename(output.path)}`;
|
|
67
|
+
const scriptKind = ext === ".js" ? compiledScriptKind(fileName) : undefined;
|
|
68
|
+
return {
|
|
69
|
+
path: publicPath,
|
|
70
|
+
contentType: contentTypeForOutput(ext),
|
|
71
|
+
role: ext === ".css" ? "style" : scriptKind ? "script" : "asset",
|
|
72
|
+
...(scriptKind ? { scriptOrder: scriptKind === "renderer" ? 0 : 1 } : {}),
|
|
73
|
+
body: readFileSync(output.path).toString("base64"),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const compiledAssetsModule = resolve(cacheDir, "compiled-webapp-assets.ts");
|
|
77
|
+
writeFileSync(compiledAssetsModule, `globalThis[Symbol.for("webapp.compiledClient")] = ${JSON.stringify({ packageRoot, assets })};
|
|
78
|
+
`);
|
|
79
|
+
const compiledEntrypoint = resolve(cacheDir, "entrypoint.ts");
|
|
80
|
+
writeFileSync(compiledEntrypoint, `import "./compiled-webapp-assets";
|
|
81
|
+
import ${JSON.stringify(resolve(options.entrypoint))};
|
|
82
|
+
`);
|
|
83
|
+
const result = await Bun.build({
|
|
84
|
+
entrypoints: [compiledEntrypoint],
|
|
85
|
+
target: "bun",
|
|
86
|
+
minify: true,
|
|
87
|
+
sourcemap: "external",
|
|
88
|
+
define: options.define,
|
|
89
|
+
compile: options.target ? { target: options.target, outfile: options.outfile } : { outfile: options.outfile },
|
|
90
|
+
});
|
|
91
|
+
if (!result.success) {
|
|
92
|
+
for (const log of result.logs) {
|
|
93
|
+
console.error(log);
|
|
94
|
+
}
|
|
95
|
+
throw new Error("Binary build failed");
|
|
96
|
+
}
|
|
97
|
+
if (process.platform !== "win32" && !options.target?.startsWith("bun-windows")) {
|
|
98
|
+
chmodSync(options.outfile, 0o755);
|
|
36
99
|
}
|
|
37
|
-
|
|
100
|
+
} finally {
|
|
101
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
38
102
|
}
|
|
39
|
-
|
|
40
|
-
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function findPackageRoot(start: string): string {
|
|
106
|
+
let current = start;
|
|
107
|
+
while (true) {
|
|
108
|
+
if (existsSync(resolve(current, "package.json"))) {
|
|
109
|
+
return current;
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(current);
|
|
112
|
+
if (parent === current) return start;
|
|
113
|
+
current = parent;
|
|
41
114
|
}
|
|
42
115
|
}
|
|
116
|
+
|
|
117
|
+
function contentTypeForOutput(ext: string): string {
|
|
118
|
+
if (ext === ".js") return "text/javascript; charset=utf-8";
|
|
119
|
+
if (ext === ".css") return "text/css; charset=utf-8";
|
|
120
|
+
if (ext === ".svg") return "image/svg+xml; charset=utf-8";
|
|
121
|
+
if (ext === ".png") return "image/png";
|
|
122
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
123
|
+
if (ext === ".webp") return "image/webp";
|
|
124
|
+
if (ext === ".map") return "application/json; charset=utf-8";
|
|
125
|
+
return "application/octet-stream";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function compiledScriptKind(fileName: string): "renderer" | "client" | undefined {
|
|
129
|
+
if (/^webapp-renderer-prelude(?:[-.][\w-]+)?\.(?:mjs|js)$/.test(fileName)) return "renderer";
|
|
130
|
+
if (/^webapp-client-entry(?:[-.][\w-]+)?\.(?:mjs|js)$/.test(fileName)) return "client";
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
@@ -134,7 +134,7 @@ export interface WebAppIconsConfig {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
type WebDocument = {
|
|
137
|
-
bundle
|
|
137
|
+
bundle?: HtmlBundleIndex;
|
|
138
138
|
entryPublicPath: string;
|
|
139
139
|
cacheDir: string;
|
|
140
140
|
html: string;
|
|
@@ -143,6 +143,19 @@ type WebDocument = {
|
|
|
143
143
|
generatedPublicRoutes: Record<string, PublicRouteDefinition>;
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
type CompiledClientAsset = {
|
|
147
|
+
path: string;
|
|
148
|
+
contentType: string;
|
|
149
|
+
role: "script" | "style" | "asset";
|
|
150
|
+
scriptOrder?: number;
|
|
151
|
+
body: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type CompiledClient = {
|
|
155
|
+
packageRoot: string;
|
|
156
|
+
assets: CompiledClientAsset[];
|
|
157
|
+
};
|
|
158
|
+
|
|
146
159
|
const DEFAULT_WEB_ENTRY = "./web/main.tsx";
|
|
147
160
|
const DEFAULT_THEME_COLOR = "#111827";
|
|
148
161
|
const DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
|
@@ -446,11 +459,36 @@ function iconConfig(value: string | URL | WebAppIconConfig | undefined): WebAppI
|
|
|
446
459
|
return typeof value === "object" && !(value instanceof URL) && "src" in value ? value : { src: value };
|
|
447
460
|
}
|
|
448
461
|
|
|
449
|
-
function
|
|
462
|
+
function compiledClient(): CompiledClient | undefined {
|
|
463
|
+
const value = (globalThis as { [key: symbol]: unknown })[Symbol.for("webapp.compiledClient")];
|
|
464
|
+
if (!value || typeof value !== "object") return undefined;
|
|
465
|
+
const candidate = value as Partial<CompiledClient>;
|
|
466
|
+
return typeof candidate.packageRoot === "string" && Array.isArray(candidate.assets) ? candidate as CompiledClient : undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function generatedHtml(
|
|
470
|
+
config: RuntimeConfig,
|
|
471
|
+
web: WebAppDocumentConfig,
|
|
472
|
+
relativeEntry: string | undefined,
|
|
473
|
+
relativePrelude: string | undefined,
|
|
474
|
+
themeColor: string,
|
|
475
|
+
faviconPath: string,
|
|
476
|
+
appleTouchPath: string,
|
|
477
|
+
compiledAssets?: CompiledClientAsset[],
|
|
478
|
+
): string {
|
|
450
479
|
const title = escapeHtml(web.title ?? config.appName);
|
|
451
480
|
const shortName = escapeAttribute(web.shortName ?? config.appName);
|
|
452
481
|
const htmlFaviconPath = faviconPath.replace(/^\//, "./");
|
|
453
482
|
const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
|
|
483
|
+
const styleTags = compiledAssets?.filter((asset) => asset.role === "style").map((asset) => ` <link rel="stylesheet" href="${escapeAttribute(asset.path)}" />`).join("\n") ?? "";
|
|
484
|
+
const scriptTags = compiledAssets
|
|
485
|
+
? compiledAssets
|
|
486
|
+
.filter((asset) => asset.role === "script")
|
|
487
|
+
.sort((left, right) => (left.scriptOrder ?? 0) - (right.scriptOrder ?? 0))
|
|
488
|
+
.map((asset) => ` <script type="module" src="${escapeAttribute(asset.path)}"></script>`)
|
|
489
|
+
.join("\n")
|
|
490
|
+
: ` <script type="module" src="${escapeAttribute(relativePrelude ?? "")}"></script>
|
|
491
|
+
<script type="module" src="${escapeAttribute(relativeEntry ?? "")}"></script>`;
|
|
454
492
|
const manifestTags = pwaEnabled(web)
|
|
455
493
|
? ` <link rel="icon" href="${escapeAttribute(htmlFaviconPath)}" />
|
|
456
494
|
<link rel="apple-touch-icon" href="${escapeAttribute(htmlAppleTouchPath)}" />
|
|
@@ -473,11 +511,11 @@ function generatedHtml(config: RuntimeConfig, web: WebAppDocumentConfig, relativ
|
|
|
473
511
|
<meta name="theme-color" content="${escapeAttribute(themeColor)}" />
|
|
474
512
|
${manifestTags} <title>${title}</title>
|
|
475
513
|
<script>${themeBootScript(themeColor)}</script>
|
|
514
|
+
${styleTags}
|
|
476
515
|
</head>
|
|
477
516
|
<body>
|
|
478
517
|
<div id="root"></div>
|
|
479
|
-
|
|
480
|
-
<script type="module" src="${escapeAttribute(relativeEntry)}"></script>
|
|
518
|
+
${scriptTags}
|
|
481
519
|
</body>
|
|
482
520
|
</html>
|
|
483
521
|
`;
|
|
@@ -485,11 +523,12 @@ ${manifestTags} <title>${title}</title>
|
|
|
485
523
|
|
|
486
524
|
async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocumentConfig | undefined): Promise<WebDocument> {
|
|
487
525
|
const web = webInput ?? {};
|
|
488
|
-
const
|
|
526
|
+
const compiled = compiledClient();
|
|
527
|
+
const entryFile = compiled ? undefined : resolveWebEntry(web.entry);
|
|
489
528
|
const themeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
|
|
490
529
|
const backgroundColor = web.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
|
|
491
|
-
const packageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || entryFile)));
|
|
492
|
-
const publicEntry = webEntryPublicPath(entryFile, packageRoot);
|
|
530
|
+
const packageRoot = compiled?.packageRoot ?? findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || (entryFile ?? "."))));
|
|
531
|
+
const publicEntry = entryFile ? webEntryPublicPath(entryFile, packageRoot) : "";
|
|
493
532
|
const cacheDir = createDocumentCacheDir(config.envPrefix);
|
|
494
533
|
const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
|
|
495
534
|
const icon = generatedIcon(config.appName, themeColor, backgroundColor);
|
|
@@ -521,8 +560,8 @@ import { configureWebAppRenderer } from ${JSON.stringify(frameworkWebPath)};
|
|
|
521
560
|
|
|
522
561
|
configureWebAppRenderer(createRoot);
|
|
523
562
|
`);
|
|
524
|
-
const relativeEntry = toWebPath(relative(cacheDir, entryFile));
|
|
525
|
-
const relativePrelude = toWebPath(relative(cacheDir, preludePath));
|
|
563
|
+
const relativeEntry = entryFile ? toWebPath(relative(cacheDir, entryFile)) : undefined;
|
|
564
|
+
const relativePrelude = entryFile ? toWebPath(relative(cacheDir, preludePath)) : undefined;
|
|
526
565
|
const faviconPath = favicon ? `/webapp-favicon${pathExtension(resolveWebAsset(favicon.src, packageRoot)) || ".png"}` : "/webapp-icon.svg";
|
|
527
566
|
const appleTouchPath = appleTouch ? `/webapp-apple-touch-icon${pathExtension(resolveWebAsset(appleTouch.src, packageRoot)) || ".png"}` : faviconPath;
|
|
528
567
|
if (favicon) {
|
|
@@ -538,18 +577,28 @@ configureWebAppRenderer(createRoot);
|
|
|
538
577
|
await copyWebAsset(manifestIconFile, resolve(cacheDir, `webapp-icon-${index + 1}${ext}`));
|
|
539
578
|
}
|
|
540
579
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
580
|
+
const compiledAssets = compiled?.assets;
|
|
581
|
+
writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, themeColor, faviconPath, appleTouchPath, compiledAssets));
|
|
582
|
+
const bundle = compiledAssets ? undefined : (await import(`${pathToFileURL(htmlPath).href}?v=${Date.now()}-${Math.random()}`)).default;
|
|
583
|
+
if (!compiledAssets && !isHtmlBundleIndex(bundle)) {
|
|
544
584
|
throw new Error("Generated web document did not produce a Bun HTMLBundle");
|
|
545
585
|
}
|
|
546
|
-
const html = await Bun.file(bundle.index).text();
|
|
586
|
+
const html = bundle ? await Bun.file(bundle.index).text() : await Bun.file(htmlPath).text();
|
|
547
587
|
const generatedPublicRoutes: Record<string, PublicRouteDefinition> = {
|
|
548
588
|
"/webapp-icon.svg": {
|
|
549
589
|
headers: { "content-type": "image/svg+xml; charset=utf-8" },
|
|
550
590
|
GET: icon,
|
|
551
591
|
},
|
|
552
592
|
};
|
|
593
|
+
if (compiledAssets) {
|
|
594
|
+
for (const asset of compiledAssets) {
|
|
595
|
+
const body = Buffer.from(asset.body, "base64");
|
|
596
|
+
generatedPublicRoutes[asset.path] = {
|
|
597
|
+
headers: { "content-type": asset.contentType },
|
|
598
|
+
GET: () => body,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
553
602
|
if (favicon) {
|
|
554
603
|
const faviconFile = resolveWebAsset(favicon.src, packageRoot);
|
|
555
604
|
generatedPublicRoutes[faviconPath] = {
|
|
@@ -718,10 +767,12 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
718
767
|
const configuredFavicon = iconConfig(input.web?.icons?.favicon);
|
|
719
768
|
const configuredAppleTouch = iconConfig(input.web?.icons?.appleTouch) ?? configuredFavicon;
|
|
720
769
|
const configuredManifestIcons = input.web?.icons?.manifest ?? [];
|
|
770
|
+
const compiled = compiledClient();
|
|
721
771
|
const webEntryFile = resolveWebEntry(input.web?.entry);
|
|
722
772
|
const webPackageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || webEntryFile)));
|
|
723
773
|
const generatedRoutePaths = new Set([
|
|
724
774
|
webEntryPublicPath(webEntryFile, webPackageRoot),
|
|
775
|
+
...(compiled?.assets.map((asset) => asset.path) ?? []),
|
|
725
776
|
"/webapp-icon.svg",
|
|
726
777
|
...(configuredFavicon ? [`/webapp-favicon${pathExtension(resolveWebAsset(configuredFavicon.src, webPackageRoot)) || ".png"}`] : []),
|
|
727
778
|
...(configuredAppleTouch ? [`/webapp-apple-touch-icon${pathExtension(resolveWebAsset(configuredAppleTouch.src, webPackageRoot)) || ".png"}`] : []),
|
|
@@ -737,7 +788,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
737
788
|
throw new Error(`publicRoutes cannot override framework-owned web route: ${path}`);
|
|
738
789
|
}
|
|
739
790
|
}
|
|
740
|
-
if (hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
|
|
791
|
+
if (document.entryPublicPath && hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
|
|
741
792
|
throw new Error(`publicRoutes cannot override framework-owned web route: ${document.entryPublicPath}`);
|
|
742
793
|
}
|
|
743
794
|
return document;
|
|
@@ -1305,17 +1356,18 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
1305
1356
|
// Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
|
|
1306
1357
|
// Wrapping it in a handler or Response, or adding route-level headers, serves
|
|
1307
1358
|
// untransformed module paths and breaks generated document routes.
|
|
1308
|
-
const spaDocumentRoute = {
|
|
1359
|
+
const spaDocumentRoute = webDocument.bundle ? {
|
|
1309
1360
|
...spaFallbackRoute,
|
|
1310
1361
|
GET: webDocument.bundle as never,
|
|
1311
1362
|
HEAD: webDocument.bundle as never,
|
|
1312
|
-
};
|
|
1363
|
+
} : spaFallbackRoute;
|
|
1364
|
+
const entryRoute = webDocument.bundle ? { [webDocument.entryPublicPath]: webDocument.bundle as never } : {};
|
|
1313
1365
|
const server = Bun.serve<WebAppWebSocketData>({
|
|
1314
1366
|
hostname: config.host,
|
|
1315
1367
|
port: config.port,
|
|
1316
1368
|
routes: {
|
|
1317
1369
|
...publicRouteHandlers,
|
|
1318
|
-
|
|
1370
|
+
...entryRoute,
|
|
1319
1371
|
"/api/*": dynamicHandler,
|
|
1320
1372
|
"/.well-known/*": dynamicHandler,
|
|
1321
1373
|
"/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
|