@pablozaiden/webapp 0.4.2 → 0.4.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/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, PWA shell metadata, 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, scoped settings, binary builds and Docker images.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -37,20 +37,13 @@ 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
- },
45
40
  routes,
46
41
  });
47
42
 
48
43
  await app.runFromCli();
49
44
  ```
50
45
 
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:
46
+ Keep install metadata static and relative to `index.html` so Bun can bundle and rewrite those app-owned assets:
54
47
 
55
48
  ```html
56
49
  <link rel="manifest" href="./site.webmanifest" />
@@ -62,7 +55,7 @@ For the recommended Bun `HTMLBundle` shape (`import webIndex from "./index.html"
62
55
  <meta name="theme-color" content="#111827" />
63
56
  ```
64
57
 
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.
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.
66
59
 
67
60
  Apps should stay one app and one binary. Use subcommands for different modes:
68
61
 
package/docs/server.md CHANGED
@@ -94,45 +94,11 @@ 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
97
+ ## App-owned web manifest metadata
98
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`).
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.
100
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:
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:
136
102
 
137
103
  ```html
138
104
  <link rel="manifest" href="./site.webmanifest" />
@@ -163,7 +129,9 @@ Use relative icon paths inside `site.webmanifest` as well:
163
129
  }
164
130
  ```
165
131
 
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.
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.
133
+
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.
167
135
 
168
136
  ## Public/static routes
169
137
 
@@ -178,7 +146,7 @@ createWebAppServer({
178
146
  });
179
147
  ```
180
148
 
181
- Only declared public routes are served this way. Unknown `/api/*` paths still return `404`, while normal frontend paths still return the React index.
149
+ Only declared public routes are served this way. Unknown `/api/*` paths still return `404`, while normal frontend `GET` and `HEAD` paths still return the React index. Other methods on unmatched frontend paths return `404` instead of the SPA fallback.
182
150
 
183
151
  ## App-owned websocket upgrades
184
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,7 +51,6 @@ export interface WebAppServerConfig<TEvent = unknown> {
51
51
  store?: WebAppStore;
52
52
  routes?: RouteTable<TEvent>;
53
53
  publicRoutes?: Record<string, PublicRouteDefinition>;
54
- pwa?: WebAppPwaConfig;
55
54
  websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
56
55
  auth?: {
57
56
  passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
@@ -67,34 +66,6 @@ export interface WebAppServerConfig<TEvent = unknown> {
67
66
  configResponse?: (req: Request, base: Readonly<WebAppConfigResponse>) => Record<string, unknown>;
68
67
  }
69
68
 
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
-
98
69
  export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
99
70
 
100
71
  export type WebAppWebSocketData = WebSocketData & {
@@ -124,24 +95,9 @@ export interface WebAppServer<TEvent = unknown> {
124
95
 
125
96
  const log = createLogger("webapp:server");
126
97
  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
98
 
130
99
  type HtmlBundleIndex = { index: string };
131
100
 
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
- }
144
-
145
101
  function bearerToken(req: Request): string | undefined {
146
102
  const header = req.headers.get("authorization")?.trim();
147
103
  if (!header) {
@@ -207,195 +163,22 @@ function requestLooksLikeNavigation(req?: Request): boolean {
207
163
  return !accept || accept.includes("text/html") || accept.includes("*/*");
208
164
  }
209
165
 
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
166
 
369
- async function htmlResponse(index: unknown, pwa?: NormalizedPwaConfig, req?: Request): Promise<Response> {
167
+ async function htmlResponse(index: unknown, req?: Request): Promise<Response> {
370
168
  if (isHtmlBundleIndex(index)) {
371
169
  if (requestLooksLikeNavigation(req)) {
372
170
  const html = await Bun.file(index.index).text();
373
- return withSecurityHeaders(new Response(injectPwaHeadTags(html, pwa), { headers: { "content-type": "text/html; charset=utf-8" } }));
171
+ return withSecurityHeaders(new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
374
172
  }
375
173
  return index as unknown as Response;
376
174
  }
377
175
  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
- }
390
176
  return withSecurityHeaders(index);
391
177
  }
392
178
  if (typeof index === "string") {
393
- return withSecurityHeaders(new Response(injectPwaHeadTags(index, pwa), { headers: { "content-type": "text/html; charset=utf-8" } }));
179
+ return withSecurityHeaders(new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } }));
394
180
  }
395
181
  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
- }
399
182
  return withSecurityHeaders(new Response(index));
400
183
  }
401
184
  return index as Response;
@@ -423,6 +206,10 @@ function canRespondWithIndex(index: unknown): boolean {
423
206
  return index instanceof Response || typeof index === "string" || index instanceof Blob || isHtmlBundleIndex(index);
424
207
  }
425
208
 
209
+ function canUseSpaFallback(req: Request): boolean {
210
+ return req.method === "GET" || req.method === "HEAD";
211
+ }
212
+
426
213
  function hasOwnPublicRoute(publicRoutes: Record<string, PublicRouteDefinition>, path: string): boolean {
427
214
  return Object.prototype.hasOwnProperty.call(publicRoutes, path);
428
215
  }
@@ -550,7 +337,6 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
550
337
  const wsPath = input.realtime?.path ?? "/api/ws";
551
338
  const routes = input.routes ?? {};
552
339
  const publicRoutes = input.publicRoutes ?? {};
553
- const pwa = normalizePwaConfig(config.appName, input.pwa);
554
340
  const appWebsockets = input.websockets ?? {};
555
341
  const passkeysEnabled = input.auth?.passkeys !== false;
556
342
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
@@ -958,7 +744,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
958
744
  return successResponse({ success: true, message: "Server is shutting down" });
959
745
  }
960
746
  if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
961
- return htmlResponse(input.index, pwa, req);
747
+ return htmlResponse(input.index, req);
962
748
  }
963
749
  } catch (error) {
964
750
  return authErrorResponse(error);
@@ -997,20 +783,6 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
997
783
  return response;
998
784
  }
999
785
 
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
-
1014
786
  async function handleMatchedRoute(req: Request, matched: NonNullable<ReturnType<typeof matchRoute<TEvent>>>, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
1015
787
  const handler = matched.route[method(req) ?? "GET"];
1016
788
  if (!handler) {
@@ -1075,10 +847,6 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
1075
847
  if (publicRoute) {
1076
848
  return publicRoute;
1077
849
  }
1078
- const manifest = handlePwaManifest(req);
1079
- if (manifest) {
1080
- return manifest;
1081
- }
1082
850
  const matched = matchRoute(routes, url.pathname);
1083
851
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
1084
852
  const builtIn = await handleBuiltIn(req, server);
@@ -1093,7 +861,10 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
1093
861
  if (matched) {
1094
862
  return handleMatchedRoute(req, matched, server);
1095
863
  }
1096
- return htmlResponse(input.index, pwa, req);
864
+ if (!canUseSpaFallback(req)) {
865
+ return withSecurityHeaders(notFound());
866
+ }
867
+ return htmlResponse(input.index, req);
1097
868
  }
1098
869
 
1099
870
  function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
@@ -1104,20 +875,31 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
1104
875
  function start(): Server<WebAppWebSocketData> {
1105
876
  const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
1106
877
  const publicRouteHandlers = Object.fromEntries(Object.keys(publicRoutes).map((path) => [path, dynamicHandler]));
1107
- const pwaRouteHandlers = pwa && !hasOwnPublicRoute(publicRoutes, pwa.manifestPath) ? { [pwa.manifestPath]: dynamicHandler } : {};
1108
878
  const indexCanRespond = canRespondWithIndex(input.index);
1109
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;
1110
893
  const server = Bun.serve<WebAppWebSocketData>({
1111
894
  hostname: config.host,
1112
895
  port: config.port,
1113
896
  routes: {
1114
897
  ...publicRouteHandlers,
1115
- ...pwaRouteHandlers,
1116
898
  "/api/*": dynamicHandler,
1117
899
  "/.well-known/*": dynamicHandler,
1118
900
  "/device": deviceAuthEnabled && (!indexCanRespond || indexIsHtmlBundle) ? input.index as never : dynamicHandler,
1119
901
  "/setup": passkeysEnabled && (!indexCanRespond || indexIsHtmlBundle) ? input.index as never : dynamicHandler,
1120
- "/*": indexCanRespond && !indexIsHtmlBundle ? dynamicHandler : input.index as never,
902
+ "/*": spaFallbackRoute,
1121
903
  },
1122
904
  websocket: {
1123
905
  open(socket) {