@pablozaiden/webapp 0.3.10 → 0.4.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/README.md +1 -1
- package/docs/getting-started.md +7 -0
- package/docs/server.md +38 -4
- package/package.json +1 -1
- package/src/server/create-web-app-server.ts +282 -5
- package/src/web/WebAppRoot.tsx +28 -3
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
|
|
package/docs/getting-started.md
CHANGED
|
@@ -37,12 +37,19 @@ 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`, serves `/manifest.webmanifest`, and injects the manifest/icon/mobile head tags into the shell HTML. Configure `icons`, `appleTouchIcon`, `startUrl`, and `scope` when your app ships specific install assets.
|
|
52
|
+
|
|
46
53
|
Apps should stay one app and one binary. Use subcommands for different modes:
|
|
47
54
|
|
|
48
55
|
```bash
|
package/docs/server.md
CHANGED
|
@@ -94,6 +94,44 @@ 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 so apps do not need to duplicate manifest and icon tags in every `index.html`. 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` and injects the installability tags into HTML shell responses:
|
|
122
|
+
|
|
123
|
+
```html
|
|
124
|
+
<link rel="manifest" href="/manifest.webmanifest" />
|
|
125
|
+
<link rel="icon" href="/web-app-manifest-192x192.png" type="image/png" sizes="192x192" />
|
|
126
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
127
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
128
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
129
|
+
<meta name="apple-mobile-web-app-title" content="MyApp" />
|
|
130
|
+
<meta name="theme-color" content="#111827" />
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
97
135
|
## Public/static routes
|
|
98
136
|
|
|
99
137
|
Declare public non-API assets explicitly with `publicRoutes`:
|
|
@@ -102,10 +140,6 @@ Declare public non-API assets explicitly with `publicRoutes`:
|
|
|
102
140
|
createWebAppServer({
|
|
103
141
|
// ...
|
|
104
142
|
publicRoutes: {
|
|
105
|
-
"/manifest.webmanifest": {
|
|
106
|
-
headers: { "content-type": "application/manifest+json" },
|
|
107
|
-
GET: manifestJson,
|
|
108
|
-
},
|
|
109
143
|
"/service-worker": serviceWorker,
|
|
110
144
|
},
|
|
111
145
|
});
|
package/package.json
CHANGED
|
@@ -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
|
|
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, "&")
|
|
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
|
+
|
|
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,11 +1104,13 @@ 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 } : {};
|
|
832
1108
|
const server = Bun.serve<WebAppWebSocketData>({
|
|
833
1109
|
hostname: config.host,
|
|
834
1110
|
port: config.port,
|
|
835
1111
|
routes: {
|
|
836
1112
|
...publicRouteHandlers,
|
|
1113
|
+
...pwaRouteHandlers,
|
|
837
1114
|
"/api/*": dynamicHandler,
|
|
838
1115
|
"/.well-known/*": dynamicHandler,
|
|
839
1116
|
"/device": deviceAuthEnabled && !canRespondWithIndex(input.index) ? input.index as never : dynamicHandler,
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
137
|
+
if (replaceWebAppRoute(next)) {
|
|
138
|
+
setRoute(next);
|
|
139
|
+
}
|
|
115
140
|
}, []);
|
|
116
141
|
return { route, navigate };
|
|
117
142
|
}
|