@pablozaiden/webapp 0.4.3 → 0.5.0

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/docs/cli.md CHANGED
@@ -13,7 +13,7 @@ const result = await dispatchCliCommand({
13
13
  defaultCommand: "serve",
14
14
  commands: {
15
15
  serve: async () => {
16
- app.start();
16
+ await app.start();
17
17
  await new Promise(() => undefined);
18
18
  return { exitCode: 0 };
19
19
  },
@@ -3,7 +3,6 @@
3
3
  Apps are one Bun application: backend routes, websocket, React UI and static assets are served by the same server. The framework does not support or require a standalone client dist directory.
4
4
 
5
5
  ```ts
6
- import webIndex from "./index.html";
7
6
  import { createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
8
7
 
9
8
  const items: Array<{ id: string; userId: string; title: string }> = [];
@@ -34,7 +33,6 @@ const routes = defineRoutes({
34
33
  const app = createWebAppServer({
35
34
  appName: "My App",
36
35
  envPrefix: "MY_APP",
37
- index: webIndex,
38
36
  auth: { passkeys: true, apiKeys: true, deviceAuth: true },
39
37
  realtime: { path: "/api/ws" },
40
38
  routes,
@@ -43,19 +41,33 @@ const app = createWebAppServer({
43
41
  await app.runFromCli();
44
42
  ```
45
43
 
46
- Keep install metadata static and relative to `index.html` so Bun can bundle and rewrite those app-owned assets:
44
+ The framework generates the HTML document, React mount point, viewport metadata, PWA manifest, default SVG icons, and the theme prepaint script. By default it uses `./web/main.tsx` relative to the Bun entry file as the frontend entrypoint, so apps only need to create that file. Override document defaults only when the app needs different metadata:
47
45
 
48
- ```html
49
- <link rel="manifest" href="./site.webmanifest" />
50
- <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
51
- <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
52
- <meta name="mobile-web-app-capable" content="yes" />
53
- <meta name="apple-mobile-web-app-capable" content="yes" />
54
- <meta name="apple-mobile-web-app-title" content="MyApp" />
55
- <meta name="theme-color" content="#111827" />
46
+ ```ts
47
+ createWebAppServer({
48
+ appName: "My App",
49
+ envPrefix: "MY_APP",
50
+ web: {
51
+ entry: "./frontend.tsx",
52
+ title: "My App",
53
+ shortName: "MyApp",
54
+ themeColor: "#111827",
55
+ backgroundColor: "#ffffff",
56
+ pwa: true,
57
+ icons: {
58
+ favicon: { src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png" },
59
+ appleTouch: { src: "./src/web/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
60
+ manifest: [
61
+ { src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
62
+ { src: "./src/web/icons/app-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
63
+ ],
64
+ },
65
+ },
66
+ routes,
67
+ });
56
68
  ```
57
69
 
58
- Place `site.webmanifest`, icons, and apple-touch icons next to `index.html`, and reference manifest icons with relative paths such as `"./web-app-manifest-192x192.png"`. A single SVG favicon is enough for lightweight examples; production apps should include PNG manifest icons and an Apple touch icon like Clanky. For string, `Blob`, or `Response` HTML indexes, include equivalent tags in the HTML you pass to the server.
70
+ PWA support is on by default, with generated initials icons unless the app provides `web.icons`. Icon paths are relative to the app package root; production apps should provide at least 192x192 and 512x512 PNG manifest icons plus an Apple touch icon. Set `web.pwa: false` only for apps that deliberately should not be installable. The standard theme preference key is `webapp.theme`; apps should not use app-specific theme storage keys.
59
71
 
60
72
  Apps should stay one app and one binary. Use subcommands for different modes:
61
73
 
package/docs/server.md CHANGED
@@ -94,44 +94,52 @@ Built-in endpoints include:
94
94
  | `/api/server/kill` | Authenticated server shutdown |
95
95
  | `/api/ws` | Realtime websocket by default |
96
96
 
97
- ## App-owned web manifest metadata
97
+ ## Framework-owned web document and PWA metadata
98
98
 
99
- `createWebAppServer` does not generate PWA manifests or inject installability tags. Apps that need install capabilities should own their manifest, icons, and HTML metadata directly.
99
+ `createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file.
100
100
 
101
- When `index` is a Bun `HTMLBundle`, place `site.webmanifest`, favicons, and apple-touch icons next to `index.html`, then reference them with relative paths so Bun can bundle, rewrite, and serve those assets:
102
-
103
- ```html
104
- <link rel="manifest" href="./site.webmanifest" />
105
- <link rel="icon" href="./favicon.ico" sizes="any" />
106
- <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
107
- <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
108
- <meta name="mobile-web-app-capable" content="yes" />
109
- <meta name="apple-mobile-web-app-capable" content="yes" />
110
- <meta name="apple-mobile-web-app-title" content="MyApp" />
111
- <meta name="theme-color" content="#111827" />
101
+ ```ts
102
+ createWebAppServer({
103
+ appName: "My App",
104
+ envPrefix: "MY_APP",
105
+ web: {
106
+ entry: "./frontend.tsx",
107
+ shortName: "MyApp",
108
+ themeColor: "#111827",
109
+ backgroundColor: "#ffffff",
110
+ pwa: true,
111
+ icons: {
112
+ favicon: { src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png" },
113
+ appleTouch: { src: "./src/web/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
114
+ manifest: [
115
+ { src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
116
+ { src: "./src/web/icons/app-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
117
+ ],
118
+ },
119
+ },
120
+ routes,
121
+ });
112
122
  ```
113
123
 
114
- Use relative icon paths inside `site.webmanifest` as well:
115
-
116
- ```json
117
- {
118
- "name": "My App",
119
- "short_name": "MyApp",
120
- "start_url": "./",
121
- "scope": "./",
122
- "display": "standalone",
123
- "background_color": "#ffffff",
124
- "theme_color": "#111827",
125
- "icons": [
126
- { "src": "./web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
127
- { "src": "./web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
128
- ]
129
- }
130
- ```
124
+ PWA metadata is enabled by default. Without `web.icons`, the framework serves `/site.webmanifest`, `/manifest.webmanifest`, and a generated SVG icon at `/webapp-icon.svg` using the app name initials. Apps with product artwork can override favicon, Apple touch icon, and manifest icons through `web.icons` while keeping the document framework-owned. Set `web.pwa: false` only for apps that intentionally should not be installable. The generated document includes the standard `webapp.theme` prepaint script; apps should use that framework preference instead of app-specific theme storage.
125
+
126
+ For a functional PWA:
127
+
128
+ | Field | Default | Use when |
129
+ | --- | --- | --- |
130
+ | `web.entry` | `./web/main.tsx` | The app frontend entrypoint lives somewhere else, such as Clanky's `./frontend.tsx` |
131
+ | `web.shortName` | `appName` | The installed app label should be shorter than the full name |
132
+ | `web.themeColor` | `#111827` | Browser chrome/install metadata should match product branding |
133
+ | `web.backgroundColor` | `#ffffff` | The manifest background should match the app splash/background |
134
+ | `web.icons.favicon` | Generated initials SVG | Browser tabs should use product artwork instead of initials |
135
+ | `web.icons.appleTouch` | `favicon` or generated initials SVG | iOS home-screen/Dock should use product artwork |
136
+ | `web.icons.manifest` | Generated initials SVG | Installed PWA icons should use product artwork at install sizes |
137
+
138
+ Icon `src` values are resolved relative to the app package root, not the server file. Use paths such as `./src/web/icons/app-192.png` for assets under `src`. Manifest icons should include at least a `192x192` and `512x512` PNG for production apps. SVG defaults are fine for lightweight examples and development, but app-store/Dock integrations vary by platform, so production apps should provide PNG manifest and Apple-touch icons.
131
139
 
132
- This colocated asset resolution is a Bun `HTMLBundle` feature. Apps that pass a string, `Blob`, or `Response` as `index` must serve `site.webmanifest`, icons, and other static assets explicitly through `publicRoutes` or another static file layer.
140
+ The framework serves the manifest at `/site.webmanifest` and `/manifest.webmanifest` and injects the manifest link at runtime so Bun does not rewrite manifest-relative icon URLs into broken asset paths. Favicon and Apple-touch links may be rewritten by Bun to `/_bun/asset/...`; that is expected and keeps hot reload/static asset handling native.
133
141
 
134
- If an app prefers `/manifest.webmanifest` or needs to serve a manifest dynamically, declare it explicitly with `publicRoutes`. The framework treats it like any other public asset and does not add manifest-specific behavior.
142
+ Service workers are not generated by the framework. Apps that need browser push, offline caches, app badge, or background sync should keep a deliberate app-owned service worker route such as `/service-worker`; normal installability does not require one.
135
143
 
136
144
  ## Public/static routes
137
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +1,8 @@
1
1
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
2
6
  import type { CurrentUser, LogLevelName, ThemePreference, WebAppConfigResponse, WebAppUserRole } from "../contracts";
3
7
  import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
4
8
  import {
@@ -46,7 +50,7 @@ import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, suc
46
50
  export interface WebAppServerConfig<TEvent = unknown> {
47
51
  appName: string;
48
52
  envPrefix: string;
49
- index: unknown;
53
+ web?: WebAppDocumentConfig;
50
54
  version?: string;
51
55
  store?: WebAppStore;
52
56
  routes?: RouteTable<TEvent>;
@@ -89,7 +93,7 @@ export interface WebAppServer<TEvent = unknown> {
89
93
  store: WebAppStore;
90
94
  realtime: RealtimeBus<TEvent>;
91
95
  handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined>;
92
- start(): Server<WebSocketData>;
96
+ start(): Promise<Server<WebSocketData>>;
93
97
  runFromCli(argv?: string[]): Promise<void>;
94
98
  }
95
99
 
@@ -98,6 +102,81 @@ const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "err
98
102
 
99
103
  type HtmlBundleIndex = { index: string };
100
104
 
105
+ export interface WebAppDocumentConfig {
106
+ entry?: string | URL;
107
+ title?: string;
108
+ shortName?: string;
109
+ lang?: string;
110
+ pwa?: boolean | WebAppPwaConfig;
111
+ themeColor?: string;
112
+ backgroundColor?: string;
113
+ icons?: WebAppIconsConfig;
114
+ }
115
+
116
+ export interface WebAppPwaConfig {
117
+ enabled?: boolean;
118
+ display?: "standalone" | "fullscreen" | "minimal-ui" | "browser";
119
+ startUrl?: string;
120
+ scope?: string;
121
+ }
122
+
123
+ export interface WebAppIconConfig {
124
+ src: string | URL;
125
+ sizes?: string;
126
+ type?: string;
127
+ purpose?: string;
128
+ }
129
+
130
+ export interface WebAppIconsConfig {
131
+ favicon?: string | URL | WebAppIconConfig;
132
+ appleTouch?: string | URL | WebAppIconConfig;
133
+ manifest?: WebAppIconConfig[];
134
+ }
135
+
136
+ type WebDocument = {
137
+ bundle: HtmlBundleIndex;
138
+ entryPublicPath: string;
139
+ cacheDir: string;
140
+ html: string;
141
+ manifest: string;
142
+ icon: string;
143
+ generatedPublicRoutes: Record<string, PublicRouteDefinition>;
144
+ };
145
+
146
+ const DEFAULT_WEB_ENTRY = "./web/main.tsx";
147
+ const DEFAULT_THEME_COLOR = "#111827";
148
+ const DEFAULT_BACKGROUND_COLOR = "#ffffff";
149
+ const WEBAPP_DOCUMENT_CACHE_PREFIX = "webapp-document-";
150
+ const documentCacheDirs = new Set<string>();
151
+ let documentCacheCleanupRegistered = false;
152
+
153
+ function cleanupDocumentCacheDir(cacheDir: string): void {
154
+ if (!documentCacheDirs.delete(cacheDir)) return;
155
+ rmSync(cacheDir, { recursive: true, force: true });
156
+ }
157
+
158
+ function cleanupDocumentCacheDirs(): void {
159
+ for (const cacheDir of Array.from(documentCacheDirs)) {
160
+ cleanupDocumentCacheDir(cacheDir);
161
+ }
162
+ }
163
+
164
+ function safeCachePathSegment(value: string): string {
165
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
166
+ }
167
+
168
+ function createDocumentCacheDir(envPrefix: string): string {
169
+ const root = join(tmpdir(), "webapp", safeCachePathSegment(envPrefix));
170
+ mkdirSync(root, { recursive: true });
171
+ const cacheDir = mkdtempSync(join(root, WEBAPP_DOCUMENT_CACHE_PREFIX));
172
+ documentCacheDirs.add(cacheDir);
173
+ if (!documentCacheCleanupRegistered) {
174
+ documentCacheCleanupRegistered = true;
175
+ process.once("exit", cleanupDocumentCacheDirs);
176
+ }
177
+ return cacheDir;
178
+ }
179
+
101
180
  function bearerToken(req: Request): string | undefined {
102
181
  const header = req.headers.get("authorization")?.trim();
103
182
  if (!header) {
@@ -164,24 +243,11 @@ function requestLooksLikeNavigation(req?: Request): boolean {
164
243
  }
165
244
 
166
245
 
167
- async function htmlResponse(index: unknown, req?: Request): Promise<Response> {
168
- if (isHtmlBundleIndex(index)) {
169
- if (requestLooksLikeNavigation(req)) {
170
- const html = await Bun.file(index.index).text();
171
- return withSecurityHeaders(new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
172
- }
173
- return index as unknown as Response;
246
+ async function htmlResponse(document: WebDocument, req?: Request): Promise<Response> {
247
+ if (!requestLooksLikeNavigation(req)) {
248
+ return withSecurityHeaders(notFound());
174
249
  }
175
- if (index instanceof Response) {
176
- return withSecurityHeaders(index);
177
- }
178
- if (typeof index === "string") {
179
- return withSecurityHeaders(new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } }));
180
- }
181
- if (index instanceof Blob) {
182
- return withSecurityHeaders(new Response(index));
183
- }
184
- return index as Response;
250
+ return withSecurityHeaders(new Response(document.html, { headers: { "content-type": "text/html; charset=utf-8" } }));
185
251
  }
186
252
 
187
253
  function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
@@ -202,10 +268,6 @@ function secureDynamicResponse(response: Response): Response {
202
268
  return response instanceof Response ? withSecurityHeaders(response) : response;
203
269
  }
204
270
 
205
- function canRespondWithIndex(index: unknown): boolean {
206
- return index instanceof Response || typeof index === "string" || index instanceof Blob || isHtmlBundleIndex(index);
207
- }
208
-
209
271
  function canUseSpaFallback(req: Request): boolean {
210
272
  return req.method === "GET" || req.method === "HEAD";
211
273
  }
@@ -214,6 +276,318 @@ function hasOwnPublicRoute(publicRoutes: Record<string, PublicRouteDefinition>,
214
276
  return Object.prototype.hasOwnProperty.call(publicRoutes, path);
215
277
  }
216
278
 
279
+ function escapeHtml(value: string): string {
280
+ return value
281
+ .replace(/&/g, "&amp;")
282
+ .replace(/</g, "&lt;")
283
+ .replace(/>/g, "&gt;")
284
+ .replace(/"/g, "&quot;");
285
+ }
286
+
287
+ function escapeAttribute(value: string): string {
288
+ return escapeHtml(value).replace(/'/g, "&#39;");
289
+ }
290
+
291
+ function normalizePublicPath(path: string): string {
292
+ const trimmed = path.trim();
293
+ if (!trimmed) return "/";
294
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
295
+ }
296
+
297
+ function toWebPath(path: string): string {
298
+ return path.split(sep).join("/");
299
+ }
300
+
301
+ function localFileUrlPath(url: URL, label: string): string {
302
+ if (url.protocol !== "file:") {
303
+ throw new Error(`${label} must be a local file path or file: URL; received ${url.protocol} URL`);
304
+ }
305
+ return fileURLToPath(url);
306
+ }
307
+
308
+ function resolveMaybeUrlString(value: string, label: string): string | undefined {
309
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) {
310
+ return undefined;
311
+ }
312
+ return localFileUrlPath(new URL(value), label);
313
+ }
314
+
315
+ function resolveWebEntry(entry: string | URL | undefined): string {
316
+ if (entry instanceof URL) {
317
+ return localFileUrlPath(entry, "web.entry");
318
+ }
319
+ const value = entry ?? DEFAULT_WEB_ENTRY;
320
+ const urlPath = resolveMaybeUrlString(value, "web.entry");
321
+ if (urlPath) {
322
+ return urlPath;
323
+ }
324
+ if (isAbsolute(value)) {
325
+ return value;
326
+ }
327
+ const mainDir = dirname(resolve(Bun.main || process.argv[1] || "."));
328
+ return resolve(mainDir, value);
329
+ }
330
+
331
+ function resolveWebAsset(src: string | URL, packageRoot: string): string {
332
+ if (src instanceof URL) {
333
+ return localFileUrlPath(src, "web.icons src");
334
+ }
335
+ const urlPath = resolveMaybeUrlString(src, "web.icons src");
336
+ if (urlPath) {
337
+ return urlPath;
338
+ }
339
+ if (isAbsolute(src)) {
340
+ return src;
341
+ }
342
+ return resolve(packageRoot, src);
343
+ }
344
+
345
+ function findPackageRoot(start: string): string {
346
+ let current = start;
347
+ while (true) {
348
+ if (existsSync(resolve(current, "package.json"))) {
349
+ return current;
350
+ }
351
+ const parent = dirname(current);
352
+ if (parent === current) {
353
+ return start;
354
+ }
355
+ current = parent;
356
+ }
357
+ }
358
+
359
+ async function copyWebAsset(src: string, dest: string): Promise<void> {
360
+ await Bun.write(dest, Bun.file(src));
361
+ }
362
+
363
+ function webEntryPublicPath(entryFile: string, packageRoot: string): string {
364
+ const relativeEntry = relative(packageRoot, entryFile);
365
+ if (relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) {
366
+ throw new Error(`web.entry must resolve inside the app package root: ${packageRoot}`);
367
+ }
368
+ return normalizePublicPath(toWebPath(relativeEntry));
369
+ }
370
+
371
+ function initialsForAppName(appName: string): string {
372
+ const words = appName.match(/[A-Za-z0-9]+/g) ?? [];
373
+ const initials = words.slice(0, 2).map((word) => word[0]?.toUpperCase()).join("");
374
+ return initials || "W";
375
+ }
376
+
377
+ function generatedIcon(appName: string, themeColor: string, backgroundColor: string): string {
378
+ const initials = escapeHtml(initialsForAppName(appName));
379
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="${escapeAttribute(appName)}">
380
+ <rect width="512" height="512" rx="112" fill="${escapeAttribute(themeColor)}"/>
381
+ <circle cx="256" cy="256" r="178" fill="${escapeAttribute(backgroundColor)}" opacity="0.14"/>
382
+ <text x="50%" y="54%" text-anchor="middle" dominant-baseline="middle" fill="${escapeAttribute(backgroundColor)}" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="176" font-weight="800">${initials}</text>
383
+ </svg>
384
+ `;
385
+ }
386
+
387
+ function pathExtension(path: string): string {
388
+ const pathname = path.includes("://") ? new URL(path).pathname : path;
389
+ const match = pathname.match(/\.([A-Za-z0-9]+)$/);
390
+ return match ? `.${match[1]}` : "";
391
+ }
392
+
393
+ function contentTypeForIcon(path: string, explicit?: string): string {
394
+ if (explicit) return explicit;
395
+ const ext = pathExtension(path).toLowerCase();
396
+ if (ext === ".svg") return "image/svg+xml";
397
+ if (ext === ".png") return "image/png";
398
+ if (ext === ".ico") return "image/x-icon";
399
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
400
+ return "application/octet-stream";
401
+ }
402
+
403
+ function themeBootScript(themeColor: string): string {
404
+ const darkThemeColor = JSON.stringify(themeColor);
405
+ const lightThemeColor = JSON.stringify(DEFAULT_BACKGROUND_COLOR);
406
+ return `(() => {
407
+ const key = "webapp.theme";
408
+ const root = document.documentElement;
409
+ const metaThemeColor = document.querySelector('meta[name="theme-color"]');
410
+ const stored = window.localStorage.getItem(key);
411
+ const preference = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
412
+ const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
413
+ const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference;
414
+ root.classList.toggle("dark", resolved === "dark");
415
+ root.style.colorScheme = resolved;
416
+ root.dataset.theme = preference;
417
+ root.dataset.resolvedTheme = resolved;
418
+ if (metaThemeColor instanceof HTMLMetaElement) metaThemeColor.content = resolved === "dark" ? ${darkThemeColor} : ${lightThemeColor};
419
+ })();`;
420
+ }
421
+
422
+ function pwaEnabled(web: WebAppDocumentConfig): boolean {
423
+ return typeof web.pwa === "object" ? web.pwa.enabled !== false : web.pwa !== false;
424
+ }
425
+
426
+ function pwaConfig(web: WebAppDocumentConfig): WebAppPwaConfig {
427
+ return typeof web.pwa === "object" ? web.pwa : {};
428
+ }
429
+
430
+ function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig, themeColor: string, backgroundColor: string, icons: WebAppIconConfig[]): string {
431
+ const pwa = pwaConfig(web);
432
+ return JSON.stringify({
433
+ name: config.appName,
434
+ short_name: web.shortName ?? config.appName,
435
+ start_url: pwa.startUrl ?? "./",
436
+ scope: pwa.scope ?? "./",
437
+ display: pwa.display ?? "standalone",
438
+ background_color: backgroundColor,
439
+ theme_color: themeColor,
440
+ icons,
441
+ }, null, 2);
442
+ }
443
+
444
+ function iconConfig(value: string | URL | WebAppIconConfig | undefined): WebAppIconConfig | undefined {
445
+ if (!value) return undefined;
446
+ return typeof value === "object" && !(value instanceof URL) && "src" in value ? value : { src: value };
447
+ }
448
+
449
+ function generatedHtml(config: RuntimeConfig, web: WebAppDocumentConfig, relativeEntry: string, relativePrelude: string, themeColor: string, faviconPath: string, appleTouchPath: string): string {
450
+ const title = escapeHtml(web.title ?? config.appName);
451
+ const shortName = escapeAttribute(web.shortName ?? config.appName);
452
+ const htmlFaviconPath = faviconPath.replace(/^\//, "./");
453
+ const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
454
+ const manifestTags = pwaEnabled(web)
455
+ ? ` <link rel="icon" href="${escapeAttribute(htmlFaviconPath)}" />
456
+ <link rel="apple-touch-icon" href="${escapeAttribute(htmlAppleTouchPath)}" />
457
+ <meta name="mobile-web-app-capable" content="yes" />
458
+ <meta name="apple-mobile-web-app-capable" content="yes" />
459
+ <meta name="apple-mobile-web-app-title" content="${shortName}" />
460
+ <script>(() => {
461
+ const manifest = document.createElement("link");
462
+ manifest.rel = "manifest";
463
+ manifest.href = "/site.webmanifest";
464
+ document.head.appendChild(manifest);
465
+ })();</script>
466
+ `
467
+ : "";
468
+ return `<!doctype html>
469
+ <html lang="${escapeAttribute(web.lang ?? "en")}">
470
+ <head>
471
+ <meta charset="utf-8" />
472
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
473
+ <meta name="theme-color" content="${escapeAttribute(themeColor)}" />
474
+ ${manifestTags} <title>${title}</title>
475
+ <script>${themeBootScript(themeColor)}</script>
476
+ </head>
477
+ <body>
478
+ <div id="root"></div>
479
+ <script type="module" src="${escapeAttribute(relativePrelude)}"></script>
480
+ <script type="module" src="${escapeAttribute(relativeEntry)}"></script>
481
+ </body>
482
+ </html>
483
+ `;
484
+ }
485
+
486
+ async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocumentConfig | undefined): Promise<WebDocument> {
487
+ const web = webInput ?? {};
488
+ const entryFile = resolveWebEntry(web.entry);
489
+ const themeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
490
+ 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);
493
+ const cacheDir = createDocumentCacheDir(config.envPrefix);
494
+ const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
495
+ const icon = generatedIcon(config.appName, themeColor, backgroundColor);
496
+ const favicon = iconConfig(web.icons?.favicon);
497
+ const appleTouch = iconConfig(web.icons?.appleTouch) ?? favicon;
498
+ const manifestIconConfigs = web.icons?.manifest?.length ? web.icons.manifest : undefined;
499
+ const manifestIcons = manifestIconConfigs
500
+ ? manifestIconConfigs.map((manifestIcon, index) => {
501
+ const srcPath = resolveWebAsset(manifestIcon.src, packageRoot);
502
+ const ext = pathExtension(srcPath) || ".png";
503
+ return {
504
+ src: `./webapp-icon-${index + 1}${ext}`,
505
+ sizes: manifestIcon.sizes ?? "any",
506
+ type: contentTypeForIcon(srcPath, manifestIcon.type),
507
+ purpose: manifestIcon.purpose ?? "any maskable",
508
+ };
509
+ })
510
+ : [{ src: "./webapp-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }];
511
+ const manifest = pwaEnabled(web) ? generatedManifest(config, web, themeColor, backgroundColor, manifestIcons) : "";
512
+ writeFileSync(resolve(cacheDir, "webapp-icon.svg"), icon);
513
+ if (manifest) {
514
+ writeFileSync(resolve(cacheDir, "site.webmanifest"), manifest);
515
+ }
516
+ const preludePath = resolve(cacheDir, "webapp-prelude.ts");
517
+ const reactDomClientPath = toWebPath(resolve(packageRoot, "node_modules/react-dom/client.js"));
518
+ const frameworkWebPath = toWebPath(fileURLToPath(new URL("../web/index.ts", import.meta.url)));
519
+ writeFileSync(preludePath, `import { createRoot } from ${JSON.stringify(reactDomClientPath)};
520
+ import { configureWebAppRenderer } from ${JSON.stringify(frameworkWebPath)};
521
+
522
+ configureWebAppRenderer(createRoot);
523
+ `);
524
+ const relativeEntry = toWebPath(relative(cacheDir, entryFile));
525
+ const relativePrelude = toWebPath(relative(cacheDir, preludePath));
526
+ const faviconPath = favicon ? `/webapp-favicon${pathExtension(resolveWebAsset(favicon.src, packageRoot)) || ".png"}` : "/webapp-icon.svg";
527
+ const appleTouchPath = appleTouch ? `/webapp-apple-touch-icon${pathExtension(resolveWebAsset(appleTouch.src, packageRoot)) || ".png"}` : faviconPath;
528
+ if (favicon) {
529
+ await copyWebAsset(resolveWebAsset(favicon.src, packageRoot), resolve(cacheDir, faviconPath.slice(1)));
530
+ }
531
+ if (appleTouch) {
532
+ await copyWebAsset(resolveWebAsset(appleTouch.src, packageRoot), resolve(cacheDir, appleTouchPath.slice(1)));
533
+ }
534
+ if (manifestIconConfigs) {
535
+ for (const [index, manifestIcon] of manifestIconConfigs.entries()) {
536
+ const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
537
+ const ext = pathExtension(manifestIconFile) || ".png";
538
+ await copyWebAsset(manifestIconFile, resolve(cacheDir, `webapp-icon-${index + 1}${ext}`));
539
+ }
540
+ }
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)) {
544
+ throw new Error("Generated web document did not produce a Bun HTMLBundle");
545
+ }
546
+ const html = await Bun.file(bundle.index).text();
547
+ const generatedPublicRoutes: Record<string, PublicRouteDefinition> = {
548
+ "/webapp-icon.svg": {
549
+ headers: { "content-type": "image/svg+xml; charset=utf-8" },
550
+ GET: icon,
551
+ },
552
+ };
553
+ if (favicon) {
554
+ const faviconFile = resolveWebAsset(favicon.src, packageRoot);
555
+ generatedPublicRoutes[faviconPath] = {
556
+ headers: { "content-type": contentTypeForIcon(faviconFile, favicon.type) },
557
+ GET: () => Bun.file(faviconFile),
558
+ };
559
+ }
560
+ if (appleTouch) {
561
+ const appleTouchFile = resolveWebAsset(appleTouch.src, packageRoot);
562
+ generatedPublicRoutes[appleTouchPath] = {
563
+ headers: { "content-type": contentTypeForIcon(appleTouchFile, appleTouch.type) },
564
+ GET: () => Bun.file(appleTouchFile),
565
+ };
566
+ }
567
+ if (manifestIconConfigs) {
568
+ manifestIconConfigs.forEach((manifestIcon, index) => {
569
+ const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
570
+ const ext = pathExtension(manifestIconFile) || ".png";
571
+ generatedPublicRoutes[`/webapp-icon-${index + 1}${ext}`] = {
572
+ headers: { "content-type": contentTypeForIcon(manifestIconFile, manifestIcon.type) },
573
+ GET: () => Bun.file(manifestIconFile),
574
+ };
575
+ });
576
+ }
577
+ if (pwaEnabled(web)) {
578
+ generatedPublicRoutes["/site.webmanifest"] = {
579
+ headers: { "content-type": "application/manifest+json; charset=utf-8" },
580
+ GET: manifest,
581
+ };
582
+ generatedPublicRoutes["/manifest.webmanifest"] = {
583
+ headers: { "content-type": "application/manifest+json; charset=utf-8" },
584
+ GET: manifest,
585
+ };
586
+ return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest, icon, generatedPublicRoutes };
587
+ }
588
+ return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest: "", icon, generatedPublicRoutes };
589
+ }
590
+
217
591
  function scopesFromBearer(claims: { scope: string }): string[] {
218
592
  return claims.scope.split(/\s+/).filter(Boolean);
219
593
  }
@@ -341,6 +715,35 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
341
715
  const passkeysEnabled = input.auth?.passkeys !== false;
342
716
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
343
717
  const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
718
+ const configuredFavicon = iconConfig(input.web?.icons?.favicon);
719
+ const configuredAppleTouch = iconConfig(input.web?.icons?.appleTouch) ?? configuredFavicon;
720
+ const configuredManifestIcons = input.web?.icons?.manifest ?? [];
721
+ const webEntryFile = resolveWebEntry(input.web?.entry);
722
+ const webPackageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || webEntryFile)));
723
+ const generatedRoutePaths = new Set([
724
+ webEntryPublicPath(webEntryFile, webPackageRoot),
725
+ "/webapp-icon.svg",
726
+ ...(configuredFavicon ? [`/webapp-favicon${pathExtension(resolveWebAsset(configuredFavicon.src, webPackageRoot)) || ".png"}`] : []),
727
+ ...(configuredAppleTouch ? [`/webapp-apple-touch-icon${pathExtension(resolveWebAsset(configuredAppleTouch.src, webPackageRoot)) || ".png"}`] : []),
728
+ ...configuredManifestIcons.map((manifestIcon, index) => `/webapp-icon-${index + 1}${pathExtension(resolveWebAsset(manifestIcon.src, webPackageRoot)) || ".png"}`),
729
+ ...(pwaEnabled(input.web ?? {}) ? ["/site.webmanifest", "/manifest.webmanifest"] : []),
730
+ ]);
731
+ let webDocumentPromise: Promise<WebDocument> | undefined;
732
+
733
+ async function ensureWebDocument(): Promise<WebDocument> {
734
+ webDocumentPromise ??= createWebDocument(config, input.web).then((document) => {
735
+ for (const path of Object.keys(document.generatedPublicRoutes)) {
736
+ if (hasOwnPublicRoute(publicRoutes, path)) {
737
+ throw new Error(`publicRoutes cannot override framework-owned web route: ${path}`);
738
+ }
739
+ }
740
+ if (hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
741
+ throw new Error(`publicRoutes cannot override framework-owned web route: ${document.entryPublicPath}`);
742
+ }
743
+ return document;
744
+ });
745
+ return await webDocumentPromise;
746
+ }
344
747
 
345
748
  function disabledAuthOwner(): CurrentUser {
346
749
  const existing = store.getOwnerUser();
@@ -744,7 +1147,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
744
1147
  return successResponse({ success: true, message: "Server is shutting down" });
745
1148
  }
746
1149
  if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
747
- return htmlResponse(input.index, req);
1150
+ return htmlResponse(await ensureWebDocument(), req);
748
1151
  }
749
1152
  } catch (error) {
750
1153
  return authErrorResponse(error);
@@ -754,6 +1157,13 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
754
1157
 
755
1158
  async function handlePublicRoute(req: Request): Promise<Response | undefined> {
756
1159
  const url = new URL(req.url);
1160
+ if (generatedRoutePaths.has(url.pathname)) {
1161
+ const webDocument = await ensureWebDocument();
1162
+ const generatedRoute = webDocument.generatedPublicRoutes[url.pathname];
1163
+ if (generatedRoute) {
1164
+ return handlePublicRouteValue(req, generatedRoute);
1165
+ }
1166
+ }
757
1167
  if (!hasOwnPublicRoute(publicRoutes, url.pathname)) {
758
1168
  return undefined;
759
1169
  }
@@ -761,6 +1171,10 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
761
1171
  if (!route) {
762
1172
  return undefined;
763
1173
  }
1174
+ return handlePublicRouteValue(req, route);
1175
+ }
1176
+
1177
+ async function handlePublicRouteValue(req: Request, route: PublicRouteDefinition): Promise<Response | undefined> {
764
1178
  const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
765
1179
  if (!methodName) {
766
1180
  return withSecurityHeaders(methodNotAllowed());
@@ -864,7 +1278,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
864
1278
  if (!canUseSpaFallback(req)) {
865
1279
  return withSecurityHeaders(notFound());
866
1280
  }
867
- return htmlResponse(input.index, req);
1281
+ return htmlResponse(await ensureWebDocument(), req);
868
1282
  }
869
1283
 
870
1284
  function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
@@ -872,34 +1286,38 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
872
1286
  return handlerName ? appWebsockets[handlerName] : undefined;
873
1287
  }
874
1288
 
875
- function start(): Server<WebAppWebSocketData> {
1289
+ async function start(): Promise<Server<WebAppWebSocketData>> {
1290
+ const webDocument = await ensureWebDocument();
876
1291
  const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
877
- const publicRouteHandlers = Object.fromEntries(Object.keys(publicRoutes).map((path) => [path, dynamicHandler]));
878
- const indexCanRespond = canRespondWithIndex(input.index);
879
- const indexIsHtmlBundle = isHtmlBundleIndex(input.index);
880
- const spaFallbackRoute = indexCanRespond && !indexIsHtmlBundle
881
- ? dynamicHandler
882
- : indexIsHtmlBundle
883
- ? {
884
- GET: input.index as never,
885
- HEAD: input.index as never,
886
- POST: dynamicHandler,
887
- PUT: dynamicHandler,
888
- PATCH: dynamicHandler,
889
- DELETE: dynamicHandler,
890
- OPTIONS: dynamicHandler,
891
- }
892
- : input.index as never;
1292
+ const publicRouteHandlers = Object.fromEntries([
1293
+ ...Object.keys(webDocument.generatedPublicRoutes),
1294
+ ...Object.keys(publicRoutes),
1295
+ ].map((path) => [path, dynamicHandler]));
1296
+ const spaFallbackRoute = {
1297
+ GET: dynamicHandler,
1298
+ HEAD: dynamicHandler,
1299
+ POST: dynamicHandler,
1300
+ PUT: dynamicHandler,
1301
+ PATCH: dynamicHandler,
1302
+ DELETE: dynamicHandler,
1303
+ OPTIONS: dynamicHandler,
1304
+ };
893
1305
  const server = Bun.serve<WebAppWebSocketData>({
894
1306
  hostname: config.host,
895
1307
  port: config.port,
896
1308
  routes: {
897
1309
  ...publicRouteHandlers,
1310
+ [webDocument.entryPublicPath]: webDocument.bundle as never,
898
1311
  "/api/*": dynamicHandler,
899
1312
  "/.well-known/*": dynamicHandler,
900
- "/device": deviceAuthEnabled && (!indexCanRespond || indexIsHtmlBundle) ? input.index as never : dynamicHandler,
901
- "/setup": passkeysEnabled && (!indexCanRespond || indexIsHtmlBundle) ? input.index as never : dynamicHandler,
902
- "/*": spaFallbackRoute,
1313
+ "/device": dynamicHandler,
1314
+ "/setup": dynamicHandler,
1315
+ "/*": {
1316
+ ...spaFallbackRoute,
1317
+ // Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
1318
+ GET: webDocument.bundle as never,
1319
+ HEAD: webDocument.bundle as never,
1320
+ },
903
1321
  },
904
1322
  websocket: {
905
1323
  open(socket) {
@@ -934,6 +1352,11 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
934
1352
  },
935
1353
  development: config.development,
936
1354
  });
1355
+ const stop = server.stop.bind(server);
1356
+ server.stop = ((closeActiveConnections?: boolean) => {
1357
+ stop(closeActiveConnections);
1358
+ cleanupDocumentCacheDir(webDocument.cacheDir);
1359
+ }) as typeof server.stop;
937
1360
  log.info(`${config.appName} server running`, { url: String(server.url) });
938
1361
  return server;
939
1362
  }
@@ -941,7 +1364,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
941
1364
  async function runFromCli(argv = Bun.argv.slice(2)): Promise<void> {
942
1365
  const command = argv[0] ?? "serve";
943
1366
  if (command === "serve") {
944
- start();
1367
+ await start();
945
1368
  return await new Promise(() => undefined);
946
1369
  }
947
1370
  if (command === "version") {
package/src/types.d.ts CHANGED
@@ -9,3 +9,8 @@ declare module "*.css" {
9
9
  const value: string;
10
10
  export default value;
11
11
  }
12
+
13
+ declare module "*.svg" {
14
+ const value: string;
15
+ export default value;
16
+ }
@@ -1,8 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
- import { createRoot, type Root } from "react-dom/client";
2
+ import type { Root } from "react-dom/client";
3
3
 
4
4
  declare global {
5
5
  var __pablozaidenWebAppRoots: WeakMap<Element, Root> | undefined;
6
+ var __pablozaidenCreateWebAppRoot: ((target: Element) => Root) | undefined;
6
7
  }
7
8
 
8
9
  function roots(): WeakMap<Element, Root> {
@@ -10,6 +11,10 @@ function roots(): WeakMap<Element, Root> {
10
11
  return globalThis.__pablozaidenWebAppRoots;
11
12
  }
12
13
 
14
+ export function configureWebAppRenderer(createRoot: (target: Element) => Root): void {
15
+ globalThis.__pablozaidenCreateWebAppRoot = createRoot;
16
+ }
17
+
13
18
  export function renderWebApp(element: ReactNode, container: Element | string = "root"): Root {
14
19
  const target = typeof container === "string" ? document.getElementById(container) : container;
15
20
  if (!target) {
@@ -18,6 +23,10 @@ export function renderWebApp(element: ReactNode, container: Element | string = "
18
23
  const registry = roots();
19
24
  let root = registry.get(target);
20
25
  if (!root) {
26
+ const createRoot = globalThis.__pablozaidenCreateWebAppRoot;
27
+ if (!createRoot) {
28
+ throw new Error("Web app renderer is not configured. Use the framework-generated document or call configureWebAppRenderer(createRoot).");
29
+ }
21
30
  root = createRoot(target);
22
31
  registry.set(target, root);
23
32
  }