@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 +1 -1
- package/docs/getting-started.md +2 -9
- package/docs/server.md +7 -39
- package/package.json +1 -1
- package/src/server/create-web-app-server.ts +26 -244
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,
|
|
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
##
|
|
97
|
+
## App-owned web manifest metadata
|
|
98
98
|
|
|
99
|
-
`createWebAppServer`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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, "&")
|
|
293
|
-
.replace(/"/g, """)
|
|
294
|
-
.replace(/</g, "<")
|
|
295
|
-
.replace(/>/g, ">");
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
"/*":
|
|
902
|
+
"/*": spaFallbackRoute,
|
|
1121
903
|
},
|
|
1122
904
|
websocket: {
|
|
1123
905
|
open(socket) {
|