@pablozaiden/webapp 0.3.10 → 0.4.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pablozaiden/webapp
2
2
 
3
- Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, realtime websocket state, scoped settings, binary builds and Docker images.
3
+ Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, realtime websocket state, PWA shell metadata, scoped settings, binary builds and Docker images.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -37,12 +37,33 @@ const app = createWebAppServer({
37
37
  index: webIndex,
38
38
  auth: { passkeys: true, apiKeys: true, deviceAuth: true },
39
39
  realtime: { path: "/api/ws" },
40
+ pwa: {
41
+ shortName: "MyApp",
42
+ themeColor: "#111827",
43
+ backgroundColor: "#ffffff",
44
+ },
40
45
  routes,
41
46
  });
42
47
 
43
48
  await app.runFromCli();
44
49
  ```
45
50
 
51
+ The `pwa` option is optional; by default the framework derives install metadata from `appName` and serves `/manifest.webmanifest`. Configure `icons`, `appleTouchIcon`, `startUrl`, and `scope` when your app serves the manifest dynamically through the framework.
52
+
53
+ For the recommended Bun `HTMLBundle` shape (`import webIndex from "./index.html"`), keep the install metadata static and relative to `index.html` so Bun can bundle and rewrite those assets:
54
+
55
+ ```html
56
+ <link rel="manifest" href="./site.webmanifest" />
57
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
58
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
59
+ <meta name="mobile-web-app-capable" content="yes" />
60
+ <meta name="apple-mobile-web-app-capable" content="yes" />
61
+ <meta name="apple-mobile-web-app-title" content="MyApp" />
62
+ <meta name="theme-color" content="#111827" />
63
+ ```
64
+
65
+ 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, the framework injects equivalent tags automatically.
66
+
46
67
  Apps should stay one app and one binary. Use subcommands for different modes:
47
68
 
48
69
  ```bash
package/docs/server.md CHANGED
@@ -94,6 +94,77 @@ Built-in endpoints include:
94
94
  | `/api/server/kill` | Authenticated server shutdown |
95
95
  | `/api/ws` | Realtime websocket by default |
96
96
 
97
+ ## PWA metadata
98
+
99
+ `createWebAppServer` provides shell-level PWA metadata. PWA support is enabled by default with `appName`-derived manifest values, `/manifest.webmanifest`, `/` start/scope, `standalone` display, and conventional icon paths (`/web-app-manifest-192x192.png`, `/web-app-manifest-512x512.png`, and `/apple-touch-icon.png`).
100
+
101
+ ```ts
102
+ createWebAppServer({
103
+ appName: "My App",
104
+ // ...
105
+ pwa: {
106
+ shortName: "MyApp",
107
+ themeColor: "#111827",
108
+ backgroundColor: "#ffffff",
109
+ display: "standalone",
110
+ icons: [
111
+ { src: "/web-app-manifest-192x192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
112
+ { src: "/web-app-manifest-512x512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
113
+ ],
114
+ appleTouchIcon: { href: "/apple-touch-icon.png", sizes: "180x180" },
115
+ startUrl: "/",
116
+ scope: "/",
117
+ },
118
+ });
119
+ ```
120
+
121
+ The framework serves the manifest with `application/manifest+json; charset=utf-8`.
122
+
123
+ For string, `Blob`, or `Response` HTML indexes, the framework also injects the installability tags into HTML shell responses:
124
+
125
+ ```html
126
+ <link rel="manifest" href="/manifest.webmanifest" />
127
+ <link rel="icon" href="/web-app-manifest-192x192.png" type="image/png" sizes="192x192" />
128
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
129
+ <meta name="mobile-web-app-capable" content="yes" />
130
+ <meta name="apple-mobile-web-app-capable" content="yes" />
131
+ <meta name="apple-mobile-web-app-title" content="MyApp" />
132
+ <meta name="theme-color" content="#111827" />
133
+ ```
134
+
135
+ For Bun `HTMLBundle` indexes imported from `index.html`, Bun must serve the HTML and generated assets directly so module rewriting and transpilation keep working. In that mode the framework still serves `/manifest.webmanifest`, but it does not mutate the HTML response. Follow the Clanky-style static asset pattern instead: place `site.webmanifest`, favicons, and apple-touch icons next to `index.html`, then reference them with relative paths so Bun can bundle and rewrite them:
136
+
137
+ ```html
138
+ <link rel="manifest" href="./site.webmanifest" />
139
+ <link rel="icon" href="./favicon.ico" sizes="any" />
140
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
141
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
142
+ <meta name="mobile-web-app-capable" content="yes" />
143
+ <meta name="apple-mobile-web-app-capable" content="yes" />
144
+ <meta name="apple-mobile-web-app-title" content="MyApp" />
145
+ <meta name="theme-color" content="#111827" />
146
+ ```
147
+
148
+ Use relative icon paths inside `site.webmanifest` as well:
149
+
150
+ ```json
151
+ {
152
+ "name": "My App",
153
+ "short_name": "MyApp",
154
+ "start_url": "./",
155
+ "scope": "./",
156
+ "display": "standalone",
157
+ "background_color": "#ffffff",
158
+ "theme_color": "#111827",
159
+ "icons": [
160
+ { "src": "./web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
161
+ { "src": "./web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ Icon files still need to exist in the app bundle or public routes; the framework advertises and serves metadata, but it does not generate image assets. Set `pwa: { enabled: false }` to opt out. Apps that already serve `/manifest.webmanifest` through `publicRoutes` can keep that override; explicit public routes take precedence over the generated manifest.
167
+
97
168
  ## Public/static routes
98
169
 
99
170
  Declare public non-API assets explicitly with `publicRoutes`:
@@ -102,10 +173,6 @@ Declare public non-API assets explicitly with `publicRoutes`:
102
173
  createWebAppServer({
103
174
  // ...
104
175
  publicRoutes: {
105
- "/manifest.webmanifest": {
106
- headers: { "content-type": "application/manifest+json" },
107
- GET: manifestJson,
108
- },
109
176
  "/service-worker": serviceWorker,
110
177
  },
111
178
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.3.10",
3
+ "version": "0.4.1",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,6 +51,7 @@ export interface WebAppServerConfig<TEvent = unknown> {
51
51
  store?: WebAppStore;
52
52
  routes?: RouteTable<TEvent>;
53
53
  publicRoutes?: Record<string, PublicRouteDefinition>;
54
+ pwa?: WebAppPwaConfig;
54
55
  websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
55
56
  auth?: {
56
57
  passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
@@ -66,6 +67,34 @@ export interface WebAppServerConfig<TEvent = unknown> {
66
67
  configResponse?: (req: Request, base: Readonly<WebAppConfigResponse>) => Record<string, unknown>;
67
68
  }
68
69
 
70
+ export type WebAppPwaDisplay = "fullscreen" | "standalone" | "minimal-ui" | "browser";
71
+
72
+ export interface WebAppPwaIcon {
73
+ src: string;
74
+ sizes?: string;
75
+ type?: string;
76
+ purpose?: string;
77
+ }
78
+
79
+ export interface WebAppPwaAppleTouchIcon {
80
+ href: string;
81
+ sizes?: string;
82
+ }
83
+
84
+ export interface WebAppPwaConfig {
85
+ enabled?: boolean;
86
+ manifestPath?: string;
87
+ appName?: string;
88
+ shortName?: string;
89
+ themeColor?: string;
90
+ backgroundColor?: string;
91
+ display?: WebAppPwaDisplay;
92
+ icons?: WebAppPwaIcon[];
93
+ appleTouchIcon?: string | WebAppPwaAppleTouchIcon | WebAppPwaAppleTouchIcon[];
94
+ startUrl?: string;
95
+ scope?: string;
96
+ }
97
+
69
98
  export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
70
99
 
71
100
  export type WebAppWebSocketData = WebSocketData & {
@@ -95,6 +124,23 @@ export interface WebAppServer<TEvent = unknown> {
95
124
 
96
125
  const log = createLogger("webapp:server");
97
126
  const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
127
+ const DEFAULT_PWA_THEME_COLOR = "#111827";
128
+ const DEFAULT_PWA_BACKGROUND_COLOR = "#ffffff";
129
+
130
+ type HtmlBundleIndex = { index: string };
131
+
132
+ interface NormalizedPwaConfig {
133
+ manifestPath: string;
134
+ appName: string;
135
+ shortName: string;
136
+ themeColor: string;
137
+ backgroundColor: string;
138
+ display: WebAppPwaDisplay;
139
+ icons: WebAppPwaIcon[];
140
+ appleTouchIcons: WebAppPwaAppleTouchIcon[];
141
+ startUrl: string;
142
+ scope: string;
143
+ }
98
144
 
99
145
  function bearerToken(req: Request): string | undefined {
100
146
  const header = req.headers.get("authorization")?.trim();
@@ -139,14 +185,217 @@ function addHeaders(response: Response, headers: Headers): Response {
139
185
  return response;
140
186
  }
141
187
 
142
- function htmlResponse(index: unknown): Response {
188
+ function isHtmlBundleIndex(index: unknown): index is HtmlBundleIndex {
189
+ return typeof index === "object"
190
+ && index !== null
191
+ && "index" in index
192
+ && typeof (index as { index?: unknown }).index === "string"
193
+ && String(index) === "[object HTMLBundle]";
194
+ }
195
+
196
+ function requestLooksLikeNavigation(req?: Request): boolean {
197
+ if (!req) {
198
+ return true;
199
+ }
200
+ const url = new URL(req.url);
201
+ const lastSegment = url.pathname.split("/").pop() ?? "";
202
+ const hasFileExtension = /\.[A-Za-z0-9]+$/.test(lastSegment);
203
+ if (hasFileExtension) {
204
+ return false;
205
+ }
206
+ const accept = req.headers.get("accept");
207
+ return !accept || accept.includes("text/html") || accept.includes("*/*");
208
+ }
209
+
210
+ function normalizePath(path: string, field: string): string {
211
+ const trimmed = path.trim();
212
+ if (!trimmed) {
213
+ throw new Error(`PWA ${field} cannot be empty`);
214
+ }
215
+ return trimmed;
216
+ }
217
+
218
+ function normalizeServerPath(path: string, field: string): string {
219
+ const normalized = normalizePath(path, field);
220
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
221
+ }
222
+
223
+ function normalizePwaIcon(icon: WebAppPwaIcon): WebAppPwaIcon {
224
+ const src = normalizePath(icon.src, "icon src");
225
+ return {
226
+ src,
227
+ ...(icon.sizes ? { sizes: icon.sizes } : {}),
228
+ ...(icon.type ? { type: icon.type } : {}),
229
+ ...(icon.purpose ? { purpose: icon.purpose } : {}),
230
+ };
231
+ }
232
+
233
+ function normalizeAppleTouchIcon(icon: string | WebAppPwaAppleTouchIcon): WebAppPwaAppleTouchIcon {
234
+ if (typeof icon === "string") {
235
+ return { href: normalizePath(icon, "appleTouchIcon") };
236
+ }
237
+ return {
238
+ href: normalizePath(icon.href, "appleTouchIcon href"),
239
+ ...(icon.sizes ? { sizes: icon.sizes } : {}),
240
+ };
241
+ }
242
+
243
+ function normalizePwaConfig(appName: string, input?: WebAppPwaConfig): NormalizedPwaConfig | undefined {
244
+ if (input?.enabled === false) {
245
+ return undefined;
246
+ }
247
+ const icons = input?.icons?.map(normalizePwaIcon) ?? [
248
+ { src: "/web-app-manifest-192x192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
249
+ { src: "/web-app-manifest-512x512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
250
+ ];
251
+ const appleTouchIconInput = input?.appleTouchIcon;
252
+ const appleTouchIcons = Array.isArray(appleTouchIconInput)
253
+ ? appleTouchIconInput.map(normalizeAppleTouchIcon)
254
+ : appleTouchIconInput
255
+ ? [normalizeAppleTouchIcon(appleTouchIconInput)]
256
+ : [{ href: "/apple-touch-icon.png" }];
257
+ return {
258
+ manifestPath: normalizeServerPath(input?.manifestPath ?? "/manifest.webmanifest", "manifestPath"),
259
+ appName: input?.appName ?? appName,
260
+ shortName: input?.shortName ?? input?.appName ?? appName,
261
+ themeColor: input?.themeColor ?? DEFAULT_PWA_THEME_COLOR,
262
+ backgroundColor: input?.backgroundColor ?? DEFAULT_PWA_BACKGROUND_COLOR,
263
+ display: input?.display ?? "standalone",
264
+ icons,
265
+ appleTouchIcons,
266
+ startUrl: normalizeServerPath(input?.startUrl ?? "/", "startUrl"),
267
+ scope: normalizeServerPath(input?.scope ?? "/", "scope"),
268
+ };
269
+ }
270
+
271
+ function pwaManifestJson(pwa: NormalizedPwaConfig): string {
272
+ return JSON.stringify({
273
+ name: pwa.appName,
274
+ short_name: pwa.shortName,
275
+ start_url: pwa.startUrl,
276
+ scope: pwa.scope,
277
+ display: pwa.display,
278
+ background_color: pwa.backgroundColor,
279
+ theme_color: pwa.themeColor,
280
+ icons: pwa.icons,
281
+ });
282
+ }
283
+
284
+ function pwaManifestResponse(pwa: NormalizedPwaConfig): Response {
285
+ return withSecurityHeaders(new Response(pwaManifestJson(pwa), {
286
+ headers: { "content-type": "application/manifest+json; charset=utf-8" },
287
+ }));
288
+ }
289
+
290
+ function escapeHtmlAttribute(value: string): string {
291
+ return value
292
+ .replace(/&/g, "&amp;")
293
+ .replace(/"/g, "&quot;")
294
+ .replace(/</g, "&lt;")
295
+ .replace(/>/g, "&gt;");
296
+ }
297
+
298
+ function hasHeadTag(html: string, pattern: RegExp): boolean {
299
+ return pattern.test(html);
300
+ }
301
+
302
+ function hasLinkRel(html: string, rel: string): boolean {
303
+ const links = html.matchAll(/<link\b[^>]*\brel\s*=\s*(["'])(.*?)\1[^>]*>/gi);
304
+ for (const link of links) {
305
+ const relValue = link[2]?.toLowerCase();
306
+ if (relValue?.split(/\s+/).includes(rel)) {
307
+ return true;
308
+ }
309
+ }
310
+ return false;
311
+ }
312
+
313
+ function pwaHeadTags(html: string, pwa: NormalizedPwaConfig): string {
314
+ const tags: string[] = [];
315
+ if (!hasLinkRel(html, "manifest")) {
316
+ tags.push(`<link rel="manifest" href="${escapeHtmlAttribute(pwa.manifestPath)}" />`);
317
+ }
318
+ if (!hasLinkRel(html, "icon")) {
319
+ for (const icon of pwa.icons) {
320
+ const attributes = [
321
+ `rel="icon"`,
322
+ `href="${escapeHtmlAttribute(icon.src)}"`,
323
+ icon.type ? `type="${escapeHtmlAttribute(icon.type)}"` : undefined,
324
+ icon.sizes ? `sizes="${escapeHtmlAttribute(icon.sizes)}"` : undefined,
325
+ ].filter(Boolean);
326
+ tags.push(`<link ${attributes.join(" ")} />`);
327
+ }
328
+ }
329
+ if (!hasLinkRel(html, "apple-touch-icon")) {
330
+ for (const icon of pwa.appleTouchIcons) {
331
+ const attributes = [
332
+ `rel="apple-touch-icon"`,
333
+ icon.sizes ? `sizes="${escapeHtmlAttribute(icon.sizes)}"` : undefined,
334
+ `href="${escapeHtmlAttribute(icon.href)}"`,
335
+ ].filter(Boolean);
336
+ tags.push(`<link ${attributes.join(" ")} />`);
337
+ }
338
+ }
339
+ if (!hasHeadTag(html, /<meta\b[^>]*\bname=["']mobile-web-app-capable["']/i)) {
340
+ tags.push(`<meta name="mobile-web-app-capable" content="yes" />`);
341
+ }
342
+ if (!hasHeadTag(html, /<meta\b[^>]*\bname=["']apple-mobile-web-app-capable["']/i)) {
343
+ tags.push(`<meta name="apple-mobile-web-app-capable" content="yes" />`);
344
+ }
345
+ if (!hasHeadTag(html, /<meta\b[^>]*\bname=["']apple-mobile-web-app-title["']/i)) {
346
+ tags.push(`<meta name="apple-mobile-web-app-title" content="${escapeHtmlAttribute(pwa.shortName)}" />`);
347
+ }
348
+ if (!hasHeadTag(html, /<meta\b[^>]*\bname=["']theme-color["']/i)) {
349
+ tags.push(`<meta name="theme-color" content="${escapeHtmlAttribute(pwa.themeColor)}" />`);
350
+ }
351
+ return tags.length > 0 ? `\n ${tags.join("\n ")}\n` : "";
352
+ }
353
+
354
+ function injectPwaHeadTags(html: string, pwa?: NormalizedPwaConfig): string {
355
+ if (!pwa) {
356
+ return html;
357
+ }
358
+ const tags = pwaHeadTags(html, pwa);
359
+ if (!tags) {
360
+ return html;
361
+ }
362
+ const headClose = /<\/head\s*>/i;
363
+ if (headClose.test(html)) {
364
+ return html.replace(headClose, `${tags}</head>`);
365
+ }
366
+ return `${html}${tags}`;
367
+ }
368
+
369
+ async function htmlResponse(index: unknown, pwa?: NormalizedPwaConfig, req?: Request): Promise<Response> {
370
+ if (isHtmlBundleIndex(index)) {
371
+ if (requestLooksLikeNavigation(req)) {
372
+ const html = await Bun.file(index.index).text();
373
+ return withSecurityHeaders(new Response(injectPwaHeadTags(html, pwa), { headers: { "content-type": "text/html; charset=utf-8" } }));
374
+ }
375
+ return index as unknown as Response;
376
+ }
143
377
  if (index instanceof Response) {
378
+ const contentType = index.headers.get("content-type");
379
+ if (pwa && (!contentType || contentType.includes("text/html"))) {
380
+ const headers = new Headers(index.headers);
381
+ if (!headers.has("content-type")) {
382
+ headers.set("content-type", "text/html; charset=utf-8");
383
+ }
384
+ return withSecurityHeaders(new Response(injectPwaHeadTags(await index.clone().text(), pwa), {
385
+ status: index.status,
386
+ statusText: index.statusText,
387
+ headers,
388
+ }));
389
+ }
144
390
  return withSecurityHeaders(index);
145
391
  }
146
392
  if (typeof index === "string") {
147
- return withSecurityHeaders(new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } }));
393
+ return withSecurityHeaders(new Response(injectPwaHeadTags(index, pwa), { headers: { "content-type": "text/html; charset=utf-8" } }));
148
394
  }
149
395
  if (index instanceof Blob) {
396
+ if (pwa && (!index.type || index.type.includes("text/html"))) {
397
+ return withSecurityHeaders(new Response(injectPwaHeadTags(await index.text(), pwa), { headers: { "content-type": index.type || "text/html; charset=utf-8" } }));
398
+ }
150
399
  return withSecurityHeaders(new Response(index));
151
400
  }
152
401
  return index as Response;
@@ -171,7 +420,11 @@ function secureDynamicResponse(response: Response): Response {
171
420
  }
172
421
 
173
422
  function canRespondWithIndex(index: unknown): boolean {
174
- return index instanceof Response || typeof index === "string" || index instanceof Blob;
423
+ return index instanceof Response || typeof index === "string" || index instanceof Blob || isHtmlBundleIndex(index);
424
+ }
425
+
426
+ function hasOwnPublicRoute(publicRoutes: Record<string, PublicRouteDefinition>, path: string): boolean {
427
+ return Object.prototype.hasOwnProperty.call(publicRoutes, path);
175
428
  }
176
429
 
177
430
  function scopesFromBearer(claims: { scope: string }): string[] {
@@ -297,6 +550,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
297
550
  const wsPath = input.realtime?.path ?? "/api/ws";
298
551
  const routes = input.routes ?? {};
299
552
  const publicRoutes = input.publicRoutes ?? {};
553
+ const pwa = normalizePwaConfig(config.appName, input.pwa);
300
554
  const appWebsockets = input.websockets ?? {};
301
555
  const passkeysEnabled = input.auth?.passkeys !== false;
302
556
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
@@ -704,7 +958,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
704
958
  return successResponse({ success: true, message: "Server is shutting down" });
705
959
  }
706
960
  if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
707
- return htmlResponse(input.index);
961
+ return htmlResponse(input.index, pwa, req);
708
962
  }
709
963
  } catch (error) {
710
964
  return authErrorResponse(error);
@@ -714,6 +968,9 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
714
968
 
715
969
  async function handlePublicRoute(req: Request): Promise<Response | undefined> {
716
970
  const url = new URL(req.url);
971
+ if (!hasOwnPublicRoute(publicRoutes, url.pathname)) {
972
+ return undefined;
973
+ }
717
974
  const route = publicRoutes[url.pathname];
718
975
  if (!route) {
719
976
  return undefined;
@@ -740,6 +997,20 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
740
997
  return response;
741
998
  }
742
999
 
1000
+ function handlePwaManifest(req: Request): Response | undefined {
1001
+ if (!pwa || new URL(req.url).pathname !== pwa.manifestPath || hasOwnPublicRoute(publicRoutes, pwa.manifestPath)) {
1002
+ return undefined;
1003
+ }
1004
+ if (req.method !== "GET" && req.method !== "HEAD") {
1005
+ return withSecurityHeaders(methodNotAllowed());
1006
+ }
1007
+ const response = pwaManifestResponse(pwa);
1008
+ if (req.method === "HEAD") {
1009
+ return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
1010
+ }
1011
+ return response;
1012
+ }
1013
+
743
1014
  async function handleMatchedRoute(req: Request, matched: NonNullable<ReturnType<typeof matchRoute<TEvent>>>, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
744
1015
  const handler = matched.route[method(req) ?? "GET"];
745
1016
  if (!handler) {
@@ -804,6 +1075,10 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
804
1075
  if (publicRoute) {
805
1076
  return publicRoute;
806
1077
  }
1078
+ const manifest = handlePwaManifest(req);
1079
+ if (manifest) {
1080
+ return manifest;
1081
+ }
807
1082
  const matched = matchRoute(routes, url.pathname);
808
1083
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
809
1084
  const builtIn = await handleBuiltIn(req, server);
@@ -818,7 +1093,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
818
1093
  if (matched) {
819
1094
  return handleMatchedRoute(req, matched, server);
820
1095
  }
821
- return htmlResponse(input.index);
1096
+ return htmlResponse(input.index, pwa, req);
822
1097
  }
823
1098
 
824
1099
  function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
@@ -829,16 +1104,20 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
829
1104
  function start(): Server<WebAppWebSocketData> {
830
1105
  const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
831
1106
  const publicRouteHandlers = Object.fromEntries(Object.keys(publicRoutes).map((path) => [path, dynamicHandler]));
1107
+ const pwaRouteHandlers = pwa && !hasOwnPublicRoute(publicRoutes, pwa.manifestPath) ? { [pwa.manifestPath]: dynamicHandler } : {};
1108
+ const indexCanRespond = canRespondWithIndex(input.index);
1109
+ const indexIsHtmlBundle = isHtmlBundleIndex(input.index);
832
1110
  const server = Bun.serve<WebAppWebSocketData>({
833
1111
  hostname: config.host,
834
1112
  port: config.port,
835
1113
  routes: {
836
1114
  ...publicRouteHandlers,
1115
+ ...pwaRouteHandlers,
837
1116
  "/api/*": dynamicHandler,
838
1117
  "/.well-known/*": dynamicHandler,
839
- "/device": deviceAuthEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
840
- "/setup": passkeysEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
841
- "/*": canRespondWithIndex(input.index) ? dynamicHandler : input.index as never,
1118
+ "/device": deviceAuthEnabled && (!indexCanRespond || indexIsHtmlBundle) ? input.index as never : dynamicHandler,
1119
+ "/setup": passkeysEnabled && (!indexCanRespond || indexIsHtmlBundle) ? input.index as never : dynamicHandler,
1120
+ "/*": indexCanRespond && !indexIsHtmlBundle ? dynamicHandler : input.index as never,
842
1121
  },
843
1122
  websocket: {
844
1123
  open(socket) {
@@ -84,7 +84,7 @@ function isSidebarShortcutEditableTarget(target: EventTarget | null): boolean {
84
84
  || target instanceof HTMLSelectElement;
85
85
  }
86
86
 
87
- function routeToHash(route: WebAppRoute): string {
87
+ export function routeToHash(route: WebAppRoute): string {
88
88
  const params = new URLSearchParams();
89
89
  for (const [key, value] of Object.entries(route)) {
90
90
  if (key !== "view" && value !== undefined) {
@@ -94,6 +94,30 @@ function routeToHash(route: WebAppRoute): string {
94
94
  return `#/${route.view}${params.size ? `?${params.toString()}` : ""}`;
95
95
  }
96
96
 
97
+ export function replaceHashRoute(hash: string): boolean {
98
+ const normalizedHash = hash.startsWith("#") ? hash : `#${hash}`;
99
+ if (window.location.hash === normalizedHash) {
100
+ return false;
101
+ }
102
+
103
+ const previousUrl = window.location.href;
104
+ let hashChangeEmitted = false;
105
+ const markHashChangeEmitted = () => {
106
+ hashChangeEmitted = true;
107
+ };
108
+ window.addEventListener("hashchange", markHashChangeEmitted, { once: true });
109
+ window.history.replaceState(window.history.state, "", normalizedHash);
110
+ window.removeEventListener("hashchange", markHashChangeEmitted);
111
+ if (!hashChangeEmitted) {
112
+ window.dispatchEvent(new HashChangeEvent("hashchange", { oldURL: previousUrl, newURL: window.location.href }));
113
+ }
114
+ return true;
115
+ }
116
+
117
+ export function replaceWebAppRoute(route: WebAppRoute): boolean {
118
+ return replaceHashRoute(routeToHash(route));
119
+ }
120
+
97
121
  function parseRoute(defaultRoute: WebAppRoute): WebAppRoute {
98
122
  const hash = window.location.hash.replace(/^#\/?/, "");
99
123
  if (!hash) return defaultRoute;
@@ -110,8 +134,9 @@ function useRoute(defaultRoute: WebAppRoute) {
110
134
  return () => window.removeEventListener("hashchange", listener);
111
135
  }, [defaultRoute]);
112
136
  const navigate = useCallback((next: WebAppRoute) => {
113
- window.location.hash = routeToHash(next);
114
- setRoute(next);
137
+ if (replaceWebAppRoute(next)) {
138
+ setRoute(next);
139
+ }
115
140
  }, []);
116
141
  return { route, navigate };
117
142
  }