@pablozaiden/webapp 0.5.1 → 0.5.2

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