@pablozaiden/webapp 0.5.8 → 0.6.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.
Files changed (55) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/public-route-dispatch.ts +85 -0
  27. package/src/server/request-schemas.ts +144 -0
  28. package/src/server/responses.ts +69 -3
  29. package/src/server/route-dispatch.ts +109 -0
  30. package/src/server/runtime-config.ts +67 -0
  31. package/src/server/same-origin.ts +1 -1
  32. package/src/server/server-lifecycle.ts +138 -0
  33. package/src/server/server-types.ts +87 -0
  34. package/src/server/web-document.ts +532 -0
  35. package/src/web/WebAppRoot.tsx +69 -1214
  36. package/src/web/api-client.ts +14 -4
  37. package/src/web/app-shell.tsx +198 -0
  38. package/src/web/auth-screens.tsx +186 -0
  39. package/src/web/components/index.tsx +10 -3
  40. package/src/web/index.ts +1 -0
  41. package/src/web/mobile-hooks.ts +194 -0
  42. package/src/web/mobile.ts +12 -0
  43. package/src/web/root-types.ts +61 -0
  44. package/src/web/routing.ts +61 -0
  45. package/src/web/settings/account-section.tsx +52 -0
  46. package/src/web/settings/resource-state.tsx +17 -0
  47. package/src/web/settings/security-section.tsx +119 -0
  48. package/src/web/settings/sessions-section.tsx +66 -0
  49. package/src/web/settings/settings-view.tsx +96 -0
  50. package/src/web/settings/shutdown-section.tsx +95 -0
  51. package/src/web/settings/user-management.tsx +123 -0
  52. package/src/web/settings-view.tsx +3 -0
  53. package/src/web/sidebar-state.ts +108 -0
  54. package/src/web/sidebar-tree.tsx +89 -0
  55. package/src/web/styles.css +76 -64
@@ -1,281 +1,36 @@
1
- import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
- import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
6
- import type { CurrentUser, LogLevelName, ThemePreference, WebAppConfigResponse, WebAppUserRole } from "../contracts";
7
- import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
8
- import {
9
- approveDevice,
10
- createDeviceAuthorization,
11
- denyDevice,
12
- discovery,
13
- exchangeDeviceCode,
14
- exchangeRefreshToken,
15
- getDeviceVerificationDetails,
16
- jwks,
17
- listAuthSessions,
18
- revokeAuthSession,
19
- revokeRefreshToken,
20
- verifyAccessToken,
21
- } from "./auth/device-auth";
22
- import {
23
- beginAuthentication,
24
- beginBootstrapRegistration,
25
- beginOwnerPasskeySetup,
26
- beginSetupRegistration,
27
- completeAuthentication,
28
- completeBootstrapRegistration,
29
- completeOwnerPasskeySetup,
30
- completeSetupRegistration,
31
- deletePasskey,
32
- getPasskeySessionUser,
33
- getSetupDetails,
34
- isPasskeyAuthRequired,
35
- logoutHeaders,
36
- passkeyStatus,
37
- } from "./auth/passkeys";
1
+ import type { Server } from "bun";
38
2
  import { sqliteWebAppStore } from "./auth/sqlite-store";
39
- import type { WebAppStore } from "./auth/store";
40
- import { AuthError, type AuthenticatedRequestState } from "./auth/types";
41
- import { audit, assertValidUsername, createSetupLinkRecord, createUserRecord, summarizeUser } from "./auth/users";
42
- import { nowIso, randomToken } from "./auth/crypto";
43
- import { createLogger, setLogLevel } from "./logger";
44
- import { createRealtimeBus, type RealtimeBus, type WebSocketData } from "./realtime/bus";
45
- import { readRuntimeConfig, safeRuntimeConfig, type RuntimeConfig } from "./runtime-config";
46
- import { matchRoute, type RouteAuth, type RouteTable, type UserIdSelector, type UserOwnedResource, type UserScopedRealtimePublisher } from "./routes";
47
- import { checkSameOrigin } from "./same-origin";
48
- import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse, withSecurityHeaders } from "./responses";
49
-
50
- export interface WebAppServerConfig<TEvent = unknown> {
51
- appName: string;
52
- envPrefix: string;
53
- web?: WebAppDocumentConfig;
54
- version?: string;
55
- store?: WebAppStore;
56
- routes?: RouteTable<TEvent>;
57
- publicRoutes?: Record<string, PublicRouteDefinition>;
58
- websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
59
- auth?: {
60
- passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
61
- apiKeys?: boolean;
62
- deviceAuth?: boolean;
63
- };
64
- realtime?: {
65
- path?: string;
66
- };
67
- logLevel?: {
68
- onChange?: (level: LogLevelName) => void;
69
- };
70
- configResponse?: (req: Request, base: Readonly<WebAppConfigResponse>) => Record<string, unknown>;
71
- }
72
-
73
- export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
74
-
75
- export type WebAppWebSocketData = WebSocketData & {
76
- webappSocketHandler?: string;
77
- [key: string]: unknown;
78
- };
79
-
80
- export type PublicRouteAsset = Response | Blob | ArrayBuffer | Uint8Array | string;
81
- export type PublicRouteHandler = (req: Request) => PublicRouteAsset | undefined | Promise<PublicRouteAsset | undefined>;
82
- export type PublicRouteValue = PublicRouteAsset | PublicRouteHandler;
83
- export type PublicRouteDefinition =
84
- | PublicRouteValue
85
- | {
86
- GET?: PublicRouteValue;
87
- HEAD?: PublicRouteValue;
88
- headers?: HeadersInit;
89
- };
90
-
91
- export interface WebAppServer<TEvent = unknown> {
92
- config: RuntimeConfig;
93
- store: WebAppStore;
94
- realtime: RealtimeBus<TEvent>;
95
- handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined>;
96
- start(): Promise<Server<WebSocketData>>;
97
- runFromCli(argv?: string[]): Promise<void>;
98
- }
99
-
100
- const log = createLogger("webapp:server");
101
- const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
102
-
103
- type HtmlBundleIndex = { index: string };
104
-
105
- export interface WebAppDocumentConfig {
106
- entry?: string | URL;
107
- title?: string;
108
- shortName?: string;
109
- lang?: string;
110
- pwa?: boolean | WebAppPwaConfig;
111
- themeColor?: string;
112
- backgroundColor?: string;
113
- icons?: WebAppIconsConfig;
114
- }
115
-
116
- export interface WebAppPwaConfig {
117
- enabled?: boolean;
118
- display?: "standalone" | "fullscreen" | "minimal-ui" | "browser";
119
- startUrl?: string;
120
- scope?: string;
121
- }
122
-
123
- export interface WebAppIconConfig {
124
- src: string | URL;
125
- sizes?: string;
126
- type?: string;
127
- purpose?: string;
128
- }
129
-
130
- export interface WebAppIconsConfig {
131
- favicon?: string | URL | WebAppIconConfig;
132
- appleTouch?: string | URL | WebAppIconConfig;
133
- manifest?: WebAppIconConfig[];
134
- }
135
-
136
- type WebDocument = {
137
- bundle?: HtmlBundleIndex;
138
- entryPublicPath: string;
139
- cacheDir: string;
140
- html: string;
141
- manifest: string;
142
- icon: string;
143
- generatedPublicRoutes: Record<string, PublicRouteDefinition>;
144
- };
145
-
146
- type CompiledClientAsset = {
147
- path: string;
148
- contentType: string;
149
- role: "script" | "style" | "asset";
150
- scriptOrder?: number;
151
- body: string;
152
- };
153
-
154
- type CompiledClient = {
155
- packageRoot: string;
156
- assets: CompiledClientAsset[];
157
- };
158
-
159
- const DEFAULT_WEB_ENTRY = "./web/main.tsx";
160
- const DEFAULT_THEME_COLOR = "#111827";
161
- const DEFAULT_BACKGROUND_COLOR = "#ffffff";
162
- const WEBAPP_DOCUMENT_CACHE_PREFIX = "webapp-document-";
163
- const documentCacheDirs = new Set<string>();
164
- let documentCacheCleanupRegistered = false;
165
-
166
- function cleanupDocumentCacheDir(cacheDir: string): void {
167
- if (!documentCacheDirs.delete(cacheDir)) return;
168
- rmSync(cacheDir, { recursive: true, force: true });
169
- }
170
-
171
- function cleanupDocumentCacheDirs(): void {
172
- for (const cacheDir of Array.from(documentCacheDirs)) {
173
- cleanupDocumentCacheDir(cacheDir);
174
- }
175
- }
176
-
177
- function safeCachePathSegment(value: string): string {
178
- return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
179
- }
180
-
181
- function createDocumentCacheDir(envPrefix: string): string {
182
- const root = join(tmpdir(), "webapp", safeCachePathSegment(envPrefix));
183
- mkdirSync(root, { recursive: true });
184
- const cacheDir = realpathSync(mkdtempSync(join(root, WEBAPP_DOCUMENT_CACHE_PREFIX)));
185
- documentCacheDirs.add(cacheDir);
186
- if (!documentCacheCleanupRegistered) {
187
- documentCacheCleanupRegistered = true;
188
- process.once("exit", cleanupDocumentCacheDirs);
189
- }
190
- return cacheDir;
191
- }
192
-
193
- function bearerToken(req: Request): string | undefined {
194
- const header = req.headers.get("authorization")?.trim();
195
- if (!header) {
196
- return undefined;
197
- }
198
- const [scheme, token] = header.split(/\s+/, 2);
199
- return scheme?.toLowerCase() === "bearer" ? token : undefined;
200
- }
201
-
202
- function method(req: Request): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | undefined {
203
- const value = req.method.toUpperCase();
204
- return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE" ? value : undefined;
205
- }
206
-
207
- function tokenError(error: unknown): Response {
208
- if (error instanceof AuthError) {
209
- return jsonResponse({ error: error.code, error_description: error.message }, { status: error.status });
210
- }
211
- return jsonResponse({ error: "server_error", error_description: "An unexpected auth error occurred" }, { status: 500 });
212
- }
213
-
214
- function authErrorResponse(error: unknown): Response {
215
- if (error instanceof AuthError) {
216
- return errorResponse(error.status, error.code, error.message);
217
- }
218
- return errorResponse(500, "request_failed", "Request failed");
219
- }
220
-
221
- function routeHandlerErrorResponse(error: unknown): Response {
222
- if (error instanceof AuthError) {
223
- return errorResponse(error.status, error.code, error.message);
224
- }
225
- log.error("Unhandled route handler error", { error: error instanceof Error ? error.message : String(error) });
226
- return errorResponse(500, "request_failed", "Request failed");
227
- }
228
-
229
- function addHeaders(response: Response, headers: Headers): Response {
230
- for (const [name, value] of headers) {
231
- response.headers.append(name, value);
232
- }
233
- return response;
234
- }
235
-
236
- function isHtmlBundleIndex(index: unknown): index is HtmlBundleIndex {
237
- return typeof index === "object"
238
- && index !== null
239
- && "index" in index
240
- && typeof (index as { index?: unknown }).index === "string"
241
- && String(index) === "[object HTMLBundle]";
242
- }
243
-
244
- function requestLooksLikeNavigation(req?: Request): boolean {
245
- if (!req) {
246
- return true;
247
- }
248
- const url = new URL(req.url);
249
- const lastSegment = url.pathname.split("/").pop() ?? "";
250
- const hasFileExtension = /\.[A-Za-z0-9]+$/.test(lastSegment);
251
- if (hasFileExtension) {
252
- return false;
253
- }
254
- const accept = req.headers.get("accept");
255
- return !accept || accept.includes("text/html") || accept.includes("*/*");
256
- }
257
-
258
-
259
- async function htmlResponse(document: WebDocument, req?: Request): Promise<Response> {
260
- if (!requestLooksLikeNavigation(req)) {
261
- return withSecurityHeaders(notFound());
262
- }
263
- return withSecurityHeaders(new Response(document.html, { headers: { "content-type": "text/html; charset=utf-8" } }));
264
- }
265
-
266
- function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
267
- const response = asset instanceof Response
268
- ? asset
269
- : typeof asset === "string"
270
- ? new Response(asset, { headers: { "content-type": "text/plain; charset=utf-8" } })
271
- : new Response(asset as BodyInit);
272
- if (extraHeaders) {
273
- for (const [name, value] of new Headers(extraHeaders)) {
274
- response.headers.set(name, value);
275
- }
276
- }
277
- return withSecurityHeaders(response);
278
- }
3
+ import { createRealtimeBus } from "./realtime/bus";
4
+ import { readRuntimeConfig } from "./runtime-config";
5
+ import {
6
+ WEBAPP_SOCKET_HANDLER,
7
+ type WebAppServer,
8
+ type WebAppServerConfig,
9
+ type WebAppWebSocketData,
10
+ } from "./server-types";
11
+ import { createAuthentication } from "./authentication";
12
+ import { createFrameworkEndpointHandler } from "./framework-endpoints";
13
+ import { createServerLifecycle } from "./server-lifecycle";
14
+ import { setLogLevel } from "./logger";
15
+ import { createWebDocumentProvider, htmlResponse } from "./web-document";
16
+ import { createPublicRouteDispatcher } from "./public-route-dispatch";
17
+ import { createRouteDispatcher } from "./route-dispatch";
18
+ import { notFound, withSecurityHeaders } from "./responses";
19
+
20
+ export type {
21
+ PublicRouteAsset,
22
+ PublicRouteDefinition,
23
+ PublicRouteHandler,
24
+ PublicRouteValue,
25
+ WebAppDocumentConfig,
26
+ WebAppIconConfig,
27
+ WebAppIconsConfig,
28
+ WebAppPwaConfig,
29
+ WebAppServer,
30
+ WebAppServerConfig,
31
+ WebAppWebSocketData,
32
+ } from "./server-types";
33
+ export { WEBAPP_SOCKET_HANDLER };
279
34
 
280
35
  function secureDynamicResponse(response: Response): Response {
281
36
  return response instanceof Response ? withSecurityHeaders(response) : response;
@@ -285,471 +40,6 @@ function canUseSpaFallback(req: Request): boolean {
285
40
  return req.method === "GET" || req.method === "HEAD";
286
41
  }
287
42
 
288
- function hasOwnPublicRoute(publicRoutes: Record<string, PublicRouteDefinition>, path: string): boolean {
289
- return Object.prototype.hasOwnProperty.call(publicRoutes, path);
290
- }
291
-
292
- function escapeHtml(value: string): string {
293
- return value
294
- .replace(/&/g, "&amp;")
295
- .replace(/</g, "&lt;")
296
- .replace(/>/g, "&gt;")
297
- .replace(/"/g, "&quot;");
298
- }
299
-
300
- function escapeAttribute(value: string): string {
301
- return escapeHtml(value).replace(/'/g, "&#39;");
302
- }
303
-
304
- function normalizePublicPath(path: string): string {
305
- const trimmed = path.trim();
306
- if (!trimmed) return "/";
307
- return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
308
- }
309
-
310
- function toWebPath(path: string): string {
311
- return path.split(sep).join("/");
312
- }
313
-
314
- function localFileUrlPath(url: URL, label: string): string {
315
- if (url.protocol !== "file:") {
316
- throw new Error(`${label} must be a local file path or file: URL; received ${url.protocol} URL`);
317
- }
318
- return fileURLToPath(url);
319
- }
320
-
321
- function resolveMaybeUrlString(value: string, label: string): string | undefined {
322
- if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) {
323
- return undefined;
324
- }
325
- return localFileUrlPath(new URL(value), label);
326
- }
327
-
328
- function resolveWebEntry(entry: string | URL | undefined): string {
329
- if (entry instanceof URL) {
330
- return localFileUrlPath(entry, "web.entry");
331
- }
332
- const value = entry ?? DEFAULT_WEB_ENTRY;
333
- const urlPath = resolveMaybeUrlString(value, "web.entry");
334
- if (urlPath) {
335
- return urlPath;
336
- }
337
- if (isAbsolute(value)) {
338
- return value;
339
- }
340
- const mainDir = dirname(resolve(Bun.main || process.argv[1] || "."));
341
- return resolve(mainDir, value);
342
- }
343
-
344
- function resolveWebAsset(src: string | URL, packageRoot: string): string {
345
- if (src instanceof URL) {
346
- return localFileUrlPath(src, "web.icons src");
347
- }
348
- const urlPath = resolveMaybeUrlString(src, "web.icons src");
349
- if (urlPath) {
350
- return urlPath;
351
- }
352
- if (isAbsolute(src)) {
353
- return src;
354
- }
355
- return resolve(packageRoot, src);
356
- }
357
-
358
- function findPackageRoot(start: string): string {
359
- let current = start;
360
- while (true) {
361
- if (existsSync(resolve(current, "package.json"))) {
362
- return current;
363
- }
364
- const parent = dirname(current);
365
- if (parent === current) {
366
- return start;
367
- }
368
- current = parent;
369
- }
370
- }
371
-
372
- async function copyWebAsset(src: string, dest: string): Promise<void> {
373
- await Bun.write(dest, Bun.file(src));
374
- }
375
-
376
- function webEntryPublicPath(entryFile: string, packageRoot: string): string {
377
- const relativeEntry = relative(packageRoot, entryFile);
378
- if (relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) {
379
- throw new Error(`web.entry must resolve inside the app package root: ${packageRoot}`);
380
- }
381
- return normalizePublicPath(toWebPath(relativeEntry));
382
- }
383
-
384
- function initialsForAppName(appName: string): string {
385
- const words = appName.match(/[A-Za-z0-9]+/g) ?? [];
386
- const initials = words.slice(0, 2).map((word) => word[0]?.toUpperCase()).join("");
387
- return initials || "W";
388
- }
389
-
390
- function generatedIcon(appName: string, themeColor: string, backgroundColor: string): string {
391
- const initials = escapeHtml(initialsForAppName(appName));
392
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="${escapeAttribute(appName)}">
393
- <rect width="512" height="512" rx="112" fill="${escapeAttribute(themeColor)}"/>
394
- <circle cx="256" cy="256" r="178" fill="${escapeAttribute(backgroundColor)}" opacity="0.14"/>
395
- <text x="50%" y="54%" text-anchor="middle" dominant-baseline="middle" fill="${escapeAttribute(backgroundColor)}" font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="176" font-weight="800">${initials}</text>
396
- </svg>
397
- `;
398
- }
399
-
400
- function pathExtension(path: string): string {
401
- const pathname = path.includes("://") ? new URL(path).pathname : path;
402
- const match = pathname.match(/\.([A-Za-z0-9]+)$/);
403
- return match ? `.${match[1]}` : "";
404
- }
405
-
406
- function contentTypeForIcon(path: string, explicit?: string): string {
407
- if (explicit) return explicit;
408
- const ext = pathExtension(path).toLowerCase();
409
- if (ext === ".svg") return "image/svg+xml";
410
- if (ext === ".png") return "image/png";
411
- if (ext === ".ico") return "image/x-icon";
412
- if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
413
- return "application/octet-stream";
414
- }
415
-
416
- function themeBootScript(themeColor: string | undefined): string {
417
- const themeColorUpdate = themeColor
418
- ? `
419
- const metaThemeColor = document.querySelector('meta[name="theme-color"]');
420
- if (metaThemeColor instanceof HTMLMetaElement) metaThemeColor.content = resolved === "dark" ? ${JSON.stringify(themeColor)} : ${JSON.stringify(DEFAULT_BACKGROUND_COLOR)};`
421
- : "";
422
- return `(() => {
423
- const key = "webapp.theme";
424
- const root = document.documentElement;
425
- const stored = window.localStorage.getItem(key);
426
- const preference = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
427
- const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
428
- const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference;
429
- root.classList.toggle("dark", resolved === "dark");
430
- root.style.colorScheme = resolved;
431
- root.dataset.theme = preference;
432
- root.dataset.resolvedTheme = resolved;
433
- ${themeColorUpdate}
434
- })();`;
435
- }
436
-
437
- function pwaEnabled(web: WebAppDocumentConfig): boolean {
438
- return typeof web.pwa === "object" ? web.pwa.enabled !== false : web.pwa !== false;
439
- }
440
-
441
- function pwaConfig(web: WebAppDocumentConfig): WebAppPwaConfig {
442
- return typeof web.pwa === "object" ? web.pwa : {};
443
- }
444
-
445
- function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig, backgroundColor: string, icons: WebAppIconConfig[]): string {
446
- const pwa = pwaConfig(web);
447
- return JSON.stringify({
448
- name: config.appName,
449
- short_name: web.shortName ?? config.appName,
450
- start_url: pwa.startUrl ?? "./",
451
- scope: pwa.scope ?? "./",
452
- display: pwa.display ?? "standalone",
453
- background_color: backgroundColor,
454
- ...(web.themeColor ? { theme_color: web.themeColor } : {}),
455
- icons,
456
- }, null, 2);
457
- }
458
-
459
- function iconConfig(value: string | URL | WebAppIconConfig | undefined): WebAppIconConfig | undefined {
460
- if (!value) return undefined;
461
- return typeof value === "object" && !(value instanceof URL) && "src" in value ? value : { src: value };
462
- }
463
-
464
- function compiledClient(): CompiledClient | undefined {
465
- const value = (globalThis as { [key: symbol]: unknown })[Symbol.for("webapp.compiledClient")];
466
- if (!value || typeof value !== "object") return undefined;
467
- const candidate = value as Partial<CompiledClient>;
468
- return typeof candidate.packageRoot === "string" && Array.isArray(candidate.assets) ? candidate as CompiledClient : undefined;
469
- }
470
-
471
- function generatedHtml(
472
- config: RuntimeConfig,
473
- web: WebAppDocumentConfig,
474
- relativeEntry: string | undefined,
475
- relativePrelude: string | undefined,
476
- themeColor: string | undefined,
477
- faviconPath: string,
478
- appleTouchPath: string,
479
- compiledAssets?: CompiledClientAsset[],
480
- ): string {
481
- const title = escapeHtml(web.title ?? config.appName);
482
- const shortName = escapeAttribute(web.shortName ?? config.appName);
483
- const htmlFaviconPath = faviconPath.replace(/^\//, "./");
484
- const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
485
- const themeMetaTag = themeColor ? ` <meta name="theme-color" content="${escapeAttribute(themeColor)}" />\n` : "";
486
- const styleTags = compiledAssets?.filter((asset) => asset.role === "style").map((asset) => ` <link rel="stylesheet" href="${escapeAttribute(asset.path)}" />`).join("\n") ?? "";
487
- const scriptTags = compiledAssets
488
- ? compiledAssets
489
- .filter((asset) => asset.role === "script")
490
- .sort((left, right) => (left.scriptOrder ?? 0) - (right.scriptOrder ?? 0))
491
- .map((asset) => ` <script type="module" src="${escapeAttribute(asset.path)}"></script>`)
492
- .join("\n")
493
- : ` <script type="module" src="${escapeAttribute(relativePrelude ?? "")}"></script>
494
- <script type="module" src="${escapeAttribute(relativeEntry ?? "")}"></script>`;
495
- const manifestTags = pwaEnabled(web)
496
- ? ` <link rel="icon" href="${escapeAttribute(htmlFaviconPath)}" />
497
- <link rel="apple-touch-icon" href="${escapeAttribute(htmlAppleTouchPath)}" />
498
- <meta name="mobile-web-app-capable" content="yes" />
499
- <meta name="apple-mobile-web-app-capable" content="yes" />
500
- <meta name="apple-mobile-web-app-title" content="${shortName}" />
501
- <script>(() => {
502
- const manifest = document.createElement("link");
503
- manifest.rel = "manifest";
504
- manifest.href = "/site.webmanifest";
505
- document.head.appendChild(manifest);
506
- })();</script>
507
- `
508
- : "";
509
- return `<!doctype html>
510
- <html lang="${escapeAttribute(web.lang ?? "en")}">
511
- <head>
512
- <meta charset="utf-8" />
513
- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
514
- ${themeMetaTag}\
515
- ${manifestTags} <title>${title}</title>
516
- <script>${themeBootScript(themeColor)}</script>
517
- ${styleTags}
518
- </head>
519
- <body>
520
- <div id="root"></div>
521
- ${scriptTags}
522
- </body>
523
- </html>
524
- `;
525
- }
526
-
527
- async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocumentConfig | undefined): Promise<WebDocument> {
528
- const web = webInput ?? {};
529
- const compiled = compiledClient();
530
- const entryFile = compiled ? undefined : resolveWebEntry(web.entry);
531
- const iconThemeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
532
- const backgroundColor = web.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
533
- const packageRoot = compiled?.packageRoot ?? findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || (entryFile ?? "."))));
534
- const publicEntry = entryFile ? webEntryPublicPath(entryFile, packageRoot) : "";
535
- const cacheDir = createDocumentCacheDir(config.envPrefix);
536
- const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
537
- const icon = generatedIcon(config.appName, iconThemeColor, backgroundColor);
538
- const favicon = iconConfig(web.icons?.favicon);
539
- const appleTouch = iconConfig(web.icons?.appleTouch) ?? favicon;
540
- const manifestIconConfigs = web.icons?.manifest?.length ? web.icons.manifest : undefined;
541
- const manifestIcons = manifestIconConfigs
542
- ? manifestIconConfigs.map((manifestIcon, index) => {
543
- const srcPath = resolveWebAsset(manifestIcon.src, packageRoot);
544
- const ext = pathExtension(srcPath) || ".png";
545
- return {
546
- src: `./webapp-icon-${index + 1}${ext}`,
547
- sizes: manifestIcon.sizes ?? "any",
548
- type: contentTypeForIcon(srcPath, manifestIcon.type),
549
- purpose: manifestIcon.purpose ?? "any maskable",
550
- };
551
- })
552
- : [{ src: "./webapp-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }];
553
- const manifest = pwaEnabled(web) ? generatedManifest(config, web, backgroundColor, manifestIcons) : "";
554
- writeFileSync(resolve(cacheDir, "webapp-icon.svg"), icon);
555
- if (manifest) {
556
- writeFileSync(resolve(cacheDir, "site.webmanifest"), manifest);
557
- }
558
- const preludePath = resolve(cacheDir, "webapp-prelude.ts");
559
- const reactDomClientPath = toWebPath(resolve(packageRoot, "node_modules/react-dom/client.js"));
560
- const frameworkWebPath = toWebPath(fileURLToPath(new URL("../web/index.ts", import.meta.url)));
561
- writeFileSync(preludePath, `import { createRoot } from ${JSON.stringify(reactDomClientPath)};
562
- import { configureWebAppRenderer } from ${JSON.stringify(frameworkWebPath)};
563
-
564
- configureWebAppRenderer(createRoot);
565
- `);
566
- const relativeEntry = entryFile ? toWebPath(relative(cacheDir, entryFile)) : undefined;
567
- const relativePrelude = entryFile ? toWebPath(relative(cacheDir, preludePath)) : undefined;
568
- const faviconPath = favicon ? `/webapp-favicon${pathExtension(resolveWebAsset(favicon.src, packageRoot)) || ".png"}` : "/webapp-icon.svg";
569
- const appleTouchPath = appleTouch ? `/webapp-apple-touch-icon${pathExtension(resolveWebAsset(appleTouch.src, packageRoot)) || ".png"}` : faviconPath;
570
- if (favicon) {
571
- await copyWebAsset(resolveWebAsset(favicon.src, packageRoot), resolve(cacheDir, faviconPath.slice(1)));
572
- }
573
- if (appleTouch) {
574
- await copyWebAsset(resolveWebAsset(appleTouch.src, packageRoot), resolve(cacheDir, appleTouchPath.slice(1)));
575
- }
576
- if (manifestIconConfigs) {
577
- for (const [index, manifestIcon] of manifestIconConfigs.entries()) {
578
- const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
579
- const ext = pathExtension(manifestIconFile) || ".png";
580
- await copyWebAsset(manifestIconFile, resolve(cacheDir, `webapp-icon-${index + 1}${ext}`));
581
- }
582
- }
583
- const compiledAssets = compiled?.assets;
584
- writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, web.themeColor, faviconPath, appleTouchPath, compiledAssets));
585
- const bundle = compiledAssets ? undefined : (await import(`${pathToFileURL(htmlPath).href}?v=${Date.now()}-${Math.random()}`)).default;
586
- if (!compiledAssets && !isHtmlBundleIndex(bundle)) {
587
- throw new Error("Generated web document did not produce a Bun HTMLBundle");
588
- }
589
- const html = bundle ? await Bun.file(bundle.index).text() : await Bun.file(htmlPath).text();
590
- const generatedPublicRoutes: Record<string, PublicRouteDefinition> = {
591
- "/webapp-icon.svg": {
592
- headers: { "content-type": "image/svg+xml; charset=utf-8" },
593
- GET: icon,
594
- },
595
- };
596
- if (compiledAssets) {
597
- for (const asset of compiledAssets) {
598
- const body = Buffer.from(asset.body, "base64");
599
- generatedPublicRoutes[asset.path] = {
600
- headers: { "content-type": asset.contentType },
601
- GET: () => body,
602
- };
603
- }
604
- }
605
- if (favicon) {
606
- const faviconFile = resolveWebAsset(favicon.src, packageRoot);
607
- generatedPublicRoutes[faviconPath] = {
608
- headers: { "content-type": contentTypeForIcon(faviconFile, favicon.type) },
609
- GET: () => Bun.file(faviconFile),
610
- };
611
- }
612
- if (appleTouch) {
613
- const appleTouchFile = resolveWebAsset(appleTouch.src, packageRoot);
614
- generatedPublicRoutes[appleTouchPath] = {
615
- headers: { "content-type": contentTypeForIcon(appleTouchFile, appleTouch.type) },
616
- GET: () => Bun.file(appleTouchFile),
617
- };
618
- }
619
- if (manifestIconConfigs) {
620
- manifestIconConfigs.forEach((manifestIcon, index) => {
621
- const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
622
- const ext = pathExtension(manifestIconFile) || ".png";
623
- generatedPublicRoutes[`/webapp-icon-${index + 1}${ext}`] = {
624
- headers: { "content-type": contentTypeForIcon(manifestIconFile, manifestIcon.type) },
625
- GET: () => Bun.file(manifestIconFile),
626
- };
627
- });
628
- }
629
- if (pwaEnabled(web)) {
630
- generatedPublicRoutes["/site.webmanifest"] = {
631
- headers: { "content-type": "application/manifest+json; charset=utf-8" },
632
- GET: manifest,
633
- };
634
- generatedPublicRoutes["/manifest.webmanifest"] = {
635
- headers: { "content-type": "application/manifest+json; charset=utf-8" },
636
- GET: manifest,
637
- };
638
- return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest, icon, generatedPublicRoutes };
639
- }
640
- return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest: "", icon, generatedPublicRoutes };
641
- }
642
-
643
- function scopesFromBearer(claims: { scope: string }): string[] {
644
- return claims.scope.split(/\s+/).filter(Boolean);
645
- }
646
-
647
- function currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined {
648
- return auth.kind === "anonymous" ? undefined : auth.user;
649
- }
650
-
651
- function toCurrentUserRecord(user: { id: string; username: string; role: "owner" | "admin" | "user" }): CurrentUser {
652
- return {
653
- id: user.id,
654
- username: user.username,
655
- role: user.role,
656
- isOwner: user.role === "owner",
657
- isAdmin: user.role === "owner" || user.role === "admin",
658
- };
659
- }
660
-
661
- function requireUser(auth: AuthenticatedRequestState): CurrentUser {
662
- const user = currentUser(auth);
663
- if (!user) {
664
- throw new AuthError("authentication_required", "Authentication is required", 401);
665
- }
666
- return user;
667
- }
668
-
669
- function requireAdmin(auth: AuthenticatedRequestState): CurrentUser {
670
- const user = requireUser(auth);
671
- if (!user.isAdmin) {
672
- throw new AuthError("admin_required", "Admin permissions are required", 403);
673
- }
674
- return user;
675
- }
676
-
677
- function requireOwner(auth: AuthenticatedRequestState): CurrentUser {
678
- const user = requireUser(auth);
679
- if (!user.isOwner) {
680
- throw new AuthError("owner_required", "Owner permissions are required", 403);
681
- }
682
- return user;
683
- }
684
-
685
- function assertUser(auth: AuthenticatedRequestState, userId: string): CurrentUser {
686
- const user = requireUser(auth);
687
- if (user.id !== userId) {
688
- throw new AuthError("forbidden", "You cannot access another user's resource", 403);
689
- }
690
- return user;
691
- }
692
-
693
- function ownedUserId<TResource>(resource: TResource, getUserId?: UserIdSelector<TResource>): string | undefined {
694
- if (getUserId) {
695
- return getUserId(resource);
696
- }
697
- if (typeof resource === "object" && resource !== null && "userId" in resource && typeof resource.userId === "string") {
698
- return resource.userId;
699
- }
700
- throw new AuthError("route_misconfigured", "Owned resource helpers require a string userId field or a getUserId selector", 500);
701
- }
702
-
703
- function requireOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined): TResource;
704
- function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
705
- function requireOwned<TResource>(auth: AuthenticatedRequestState, resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
706
- const user = requireUser(auth);
707
- const resourceUserId = resource ? ownedUserId(resource, getUserId) : undefined;
708
- if (!resource || resourceUserId !== user.id) {
709
- throw new AuthError("not_found", "Resource not found", 404);
710
- }
711
- return resource;
712
- }
713
-
714
- function filterOwned<TResource extends UserOwnedResource>(auth: AuthenticatedRequestState, resources: readonly TResource[]): TResource[];
715
- function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
716
- function filterOwned<TResource>(auth: AuthenticatedRequestState, resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
717
- const user = requireUser(auth);
718
- return resources.filter((resource) => ownedUserId(resource, getUserId) === user.id);
719
- }
720
-
721
- function createFilterOwned(auth: AuthenticatedRequestState) {
722
- function contextFilterOwned<TResource extends UserOwnedResource>(resources: readonly TResource[]): TResource[];
723
- function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId: UserIdSelector<TResource>): TResource[];
724
- function contextFilterOwned<TResource>(resources: readonly TResource[], getUserId?: UserIdSelector<TResource>): TResource[] {
725
- return filterOwned(auth, resources, getUserId as UserIdSelector<TResource>);
726
- }
727
- return contextFilterOwned;
728
- }
729
-
730
- function createRequireOwned(auth: AuthenticatedRequestState) {
731
- function contextRequireOwned<TResource extends UserOwnedResource>(resource: TResource | null | undefined): TResource;
732
- function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId: UserIdSelector<TResource>): TResource;
733
- function contextRequireOwned<TResource>(resource: TResource | null | undefined, getUserId?: UserIdSelector<TResource>): TResource {
734
- return requireOwned(auth, resource, getUserId as UserIdSelector<TResource>);
735
- }
736
- return contextRequireOwned;
737
- }
738
-
739
- function requiresAuth(routeAuth: RouteAuth): boolean {
740
- return routeAuth !== "public" && routeAuth !== "optional";
741
- }
742
-
743
- function enforceRouteAuth(routeAuth: RouteAuth, auth: AuthenticatedRequestState): void {
744
- if (routeAuth === "user") {
745
- requireUser(auth);
746
- } else if (routeAuth === "admin") {
747
- requireAdmin(auth);
748
- } else if (routeAuth === "owner") {
749
- requireOwner(auth);
750
- }
751
- }
752
-
753
43
  export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<TEvent>): WebAppServer<TEvent> {
754
44
  const config = readRuntimeConfig({ appName: input.appName, envPrefix: input.envPrefix });
755
45
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
@@ -767,567 +57,60 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
767
57
  const passkeysEnabled = input.auth?.passkeys !== false;
768
58
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
769
59
  const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
770
- const configuredFavicon = iconConfig(input.web?.icons?.favicon);
771
- const configuredAppleTouch = iconConfig(input.web?.icons?.appleTouch) ?? configuredFavicon;
772
- const configuredManifestIcons = input.web?.icons?.manifest ?? [];
773
- const compiled = compiledClient();
774
- const webEntryFile = resolveWebEntry(input.web?.entry);
775
- const webPackageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || webEntryFile)));
776
- const generatedRoutePaths = new Set([
777
- webEntryPublicPath(webEntryFile, webPackageRoot),
778
- ...(compiled?.assets.map((asset) => asset.path) ?? []),
779
- "/webapp-icon.svg",
780
- ...(configuredFavicon ? [`/webapp-favicon${pathExtension(resolveWebAsset(configuredFavicon.src, webPackageRoot)) || ".png"}`] : []),
781
- ...(configuredAppleTouch ? [`/webapp-apple-touch-icon${pathExtension(resolveWebAsset(configuredAppleTouch.src, webPackageRoot)) || ".png"}`] : []),
782
- ...configuredManifestIcons.map((manifestIcon, index) => `/webapp-icon-${index + 1}${pathExtension(resolveWebAsset(manifestIcon.src, webPackageRoot)) || ".png"}`),
783
- ...(pwaEnabled(input.web ?? {}) ? ["/site.webmanifest", "/manifest.webmanifest"] : []),
784
- ]);
785
- let webDocumentPromise: Promise<WebDocument> | undefined;
786
-
787
- async function ensureWebDocument(): Promise<WebDocument> {
788
- webDocumentPromise ??= createWebDocument(config, input.web).then((document) => {
789
- for (const path of Object.keys(document.generatedPublicRoutes)) {
790
- if (hasOwnPublicRoute(publicRoutes, path)) {
791
- throw new Error(`publicRoutes cannot override framework-owned web route: ${path}`);
792
- }
793
- }
794
- if (document.entryPublicPath && hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
795
- throw new Error(`publicRoutes cannot override framework-owned web route: ${document.entryPublicPath}`);
796
- }
797
- return document;
798
- });
799
- return await webDocumentPromise;
800
- }
801
-
802
- function disabledAuthOwner(): CurrentUser {
803
- const existing = store.getOwnerUser();
804
- if (existing) {
805
- return toCurrentUserRecord(existing);
806
- }
807
- const owner = createUserRecord({ username: "admin", role: "owner" });
808
- store.createUser(owner);
809
- return toCurrentUserRecord(owner);
810
- }
811
-
812
- async function authorize(req: Request, required: boolean): Promise<AuthenticatedRequestState | Response> {
813
- const token = bearerToken(req);
814
- if (token) {
815
- if (deviceAuthEnabled) {
816
- try {
817
- const claims = await verifyAccessToken(store, config, token);
818
- const user = store.getUserById(claims.sub);
819
- if (user) {
820
- return { kind: "bearer", claims, user: {
821
- id: user.id,
822
- username: user.username,
823
- role: user.role,
824
- isOwner: user.role === "owner",
825
- isAdmin: user.role === "owner" || user.role === "admin",
826
- } };
827
- }
828
- } catch {
829
- // Fall through to API keys. Both use Bearer by design.
830
- }
831
- }
832
- if (apiKeysEnabled) {
833
- const apiKey = authenticateApiKey(store, token);
834
- if (apiKey) {
835
- return { kind: "api-key", ...apiKey };
836
- }
837
- }
838
- return errorResponse(401, "invalid_token", "Bearer token is invalid");
839
- }
840
- if (passkeysEnabled) {
841
- if (config.passkeyDisabled) {
842
- return { kind: "passkey", user: disabledAuthOwner() };
843
- }
844
- const user = getPasskeySessionUser(req, store, config);
845
- if (user) {
846
- return { kind: "passkey", user };
847
- }
848
- }
849
- if (passkeysEnabled && isPasskeyAuthRequired(store, config)) {
850
- const user = getPasskeySessionUser(req, store, config);
851
- if (!user) {
852
- return required ? errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
853
- headers: { "x-webapp-passkey-required": "true" },
854
- }) : { kind: "anonymous" };
855
- }
856
- }
857
- if (required && passkeysEnabled && !config.passkeyDisabled && store.countUsers() === 0) {
858
- return errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
859
- headers: { "x-webapp-passkey-required": "true" },
860
- });
861
- }
862
- return { kind: "anonymous" };
863
- }
864
-
865
- function configResponse(req: Request): WebAppConfigResponse & Record<string, unknown> {
866
- const user = passkeysEnabled && config.passkeyDisabled ? disabledAuthOwner() : passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
867
- const base = {
868
- appName: config.appName,
869
- version,
870
- currentUser: user,
871
- passkeyAuth: passkeyStatus(req, store, config, passkeysEnabled),
872
- userManagement: {
873
- enabled: passkeysEnabled,
874
- canManageUsers: Boolean(user?.isAdmin),
875
- },
876
- logLevel: {
877
- level: (config.logLevelFromEnv ? config.logLevel : store.getLogLevelPreference() ?? config.logLevel) as LogLevelName,
878
- fromEnv: config.logLevelFromEnv,
879
- },
880
- apiKeys: { enabled: Boolean(apiKeysEnabled) },
881
- deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
882
- } satisfies WebAppConfigResponse;
883
- return { ...(input.configResponse?.(req, base) ?? {}), ...base };
884
- }
885
-
886
- function setupUrl(req: Request, token: string): string {
887
- const url = new URL(req.url);
888
- url.pathname = "/setup";
889
- url.search = `?token=${encodeURIComponent(token)}`;
890
- url.hash = "";
891
- return url.toString();
892
- }
893
-
894
- function createSetupLink(req: Request, userId: string, kind: "invite" | "reset" | "owner-reset", actorUserId?: string) {
895
- const token = randomToken(32);
896
- const record = createSetupLinkRecord({ userId, token, kind, createdByUserId: actorUserId });
897
- store.createSetupLink(record);
898
- return { url: setupUrl(req, token), expiresAt: record.expiresAt };
899
- }
900
-
901
- function ensureAdmin(auth: AuthenticatedRequestState): CurrentUser {
902
- return requireAdmin(auth);
903
- }
904
-
905
- function sanitizeRole(role: WebAppUserRole | undefined): WebAppUserRole {
906
- return role === "admin" ? "admin" : "user";
907
- }
908
-
909
- async function handleBuiltIn(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
910
- const url = new URL(req.url);
911
- const path = url.pathname;
912
- try {
913
- if (path === "/api/health" && req.method === "GET") {
914
- return successResponse({ ok: true, version });
915
- }
916
- if (path === "/api/config" && req.method === "GET") {
917
- return jsonResponse(configResponse(req));
918
- }
919
- if (path === wsPath) {
920
- const auth = await authorize(req, true);
921
- if (auth instanceof Response) return auth;
922
- const originFailure = checkSameOrigin(req, config, auth, "always");
923
- if (originFailure) return originFailure;
924
- if (!server) return errorResponse(400, "websocket_unavailable", "WebSocket server is unavailable");
925
- const filters = Object.fromEntries(url.searchParams.entries());
926
- const upgraded = server.upgrade(req, { data: { filters, userId: currentUser(auth)?.id } });
927
- return upgraded ? undefined : errorResponse(400, "websocket_upgrade_failed", "WebSocket upgrade failed");
928
- }
929
- if (path === "/api/auth/status" && req.method === "GET") {
930
- const auth = await authorize(req, true);
931
- if (auth instanceof Response) return auth;
932
- const user = currentUser(auth);
933
- return jsonResponse({
934
- authenticated: auth.kind !== "anonymous",
935
- authKind: auth.kind,
936
- subject: user?.id ?? null,
937
- clientId: auth.kind === "bearer" ? auth.claims.clientId : null,
938
- scope: auth.kind === "bearer" ? auth.claims.scope : null,
939
- });
940
- }
941
- if (path === "/api/passkey-auth/status" && req.method === "GET") {
942
- return jsonResponse(passkeyStatus(req, store, config, passkeysEnabled));
943
- }
944
- if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/options" && req.method === "POST") {
945
- const body = await parseJson<{ username?: string }>(req);
946
- const result = await beginBootstrapRegistration(req, store, config, body.username ?? "");
947
- return addHeaders(jsonResponse(result.options), result.headers);
948
- }
949
- if (passkeysEnabled && path === "/api/passkey-auth/bootstrap/verify" && req.method === "POST") {
950
- const headers = await completeBootstrapRegistration(req, store, config, await parseJson(req));
951
- return addHeaders(successResponse(), headers);
952
- }
953
- if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/options" && req.method === "POST") {
954
- const result = await beginOwnerPasskeySetup(req, store, config);
955
- return addHeaders(jsonResponse(result.options), result.headers);
956
- }
957
- if (passkeysEnabled && path === "/api/passkey-auth/owner-setup/verify" && req.method === "POST") {
958
- const headers = await completeOwnerPasskeySetup(req, store, config, await parseJson(req));
959
- return addHeaders(successResponse(), headers);
960
- }
961
- if (passkeysEnabled && path === "/api/user-setup" && req.method === "GET") {
962
- const token = url.searchParams.get("token") ?? "";
963
- return jsonResponse(getSetupDetails(store, token));
964
- }
965
- if (passkeysEnabled && path === "/api/user-setup/options" && req.method === "POST") {
966
- const body = await parseJson<{ token?: string }>(req);
967
- const result = await beginSetupRegistration(req, store, config, body.token ?? "");
968
- return addHeaders(jsonResponse(result.options), result.headers);
969
- }
970
- if (passkeysEnabled && path === "/api/user-setup/verify" && req.method === "POST") {
971
- const body = await parseJson<{ token?: string; response?: unknown }>(req);
972
- const headers = await completeSetupRegistration(req, store, config, body.token ?? "", body.response as never);
973
- return addHeaders(successResponse(), headers);
974
- }
975
- if (passkeysEnabled && path === "/api/passkey-auth/authentication/options" && req.method === "POST") {
976
- const result = await beginAuthentication(req, store, config);
977
- return addHeaders(jsonResponse(result.options), result.headers);
978
- }
979
- if (passkeysEnabled && path === "/api/passkey-auth/authentication/verify" && req.method === "POST") {
980
- const headers = await completeAuthentication(req, store, config, await parseJson(req));
981
- return addHeaders(successResponse(), headers);
982
- }
983
- if (passkeysEnabled && path === "/api/passkey-auth/logout" && req.method === "POST") {
984
- return addHeaders(successResponse(), logoutHeaders(req, config));
985
- }
986
- if (passkeysEnabled && path === "/api/passkey-auth/passkey" && req.method === "DELETE") {
987
- const auth = await authorize(req, true);
988
- if (auth instanceof Response) return auth;
989
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
990
- if (originFailure) return originFailure;
991
- return addHeaders(successResponse(), deletePasskey(req, store, config, requireUser(auth).id));
992
- }
993
- if (apiKeysEnabled && path === "/api/api-keys" && req.method === "GET") {
994
- const auth = await authorize(req, true);
995
- if (auth instanceof Response) return auth;
996
- return jsonResponse(listApiKeys(store, requireUser(auth).id));
997
- }
998
- if (apiKeysEnabled && path === "/api/api-keys" && req.method === "POST") {
999
- const auth = await authorize(req, true);
1000
- if (auth instanceof Response) return auth;
1001
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1002
- if (originFailure) return originFailure;
1003
- return jsonResponse(createApiKey(store, requireUser(auth), await parseJson(req)));
1004
- }
1005
- const apiKeyDelete = /^\/api\/api-keys\/([^/]+)$/.exec(path);
1006
- if (apiKeysEnabled && apiKeyDelete && req.method === "DELETE") {
1007
- const auth = await authorize(req, true);
1008
- if (auth instanceof Response) return auth;
1009
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1010
- if (originFailure) return originFailure;
1011
- deleteApiKey(store, requireUser(auth).id, decodeURIComponent(apiKeyDelete[1]!));
1012
- return successResponse();
1013
- }
1014
- if (deviceAuthEnabled && path === "/api/auth/device" && req.method === "POST") {
1015
- const body = await parseJson<{ client_id?: string; clientId?: string; scope?: string }>(req).catch((): { client_id?: string; clientId?: string; scope?: string } => ({}));
1016
- return jsonResponse(createDeviceAuthorization(req, store, config, { clientId: body.client_id ?? body.clientId, scope: body.scope }));
1017
- }
1018
- if (deviceAuthEnabled && path === "/api/auth/device/verification" && req.method === "GET") {
1019
- const auth = await authorize(req, true);
1020
- if (auth instanceof Response) return auth;
1021
- const userCode = url.searchParams.get("user_code")?.trim();
1022
- if (!userCode) return errorResponse(400, "invalid_user_code", "user_code is required");
1023
- return jsonResponse(getDeviceVerificationDetails(store, userCode, passkeysEnabled && isPasskeyAuthRequired(store, config)));
1024
- }
1025
- if (deviceAuthEnabled && path === "/api/auth/device/approve" && req.method === "POST") {
1026
- const auth = await authorize(req, true);
1027
- if (auth instanceof Response) return auth;
1028
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1029
- if (originFailure) return originFailure;
1030
- const body = await parseJson<{ userCode?: string; user_code?: string }>(req);
1031
- return jsonResponse(approveDevice(store, body.userCode ?? body.user_code ?? "", requireUser(auth).id));
1032
- }
1033
- if (deviceAuthEnabled && path === "/api/auth/device/deny" && req.method === "POST") {
1034
- const auth = await authorize(req, true);
1035
- if (auth instanceof Response) return auth;
1036
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1037
- if (originFailure) return originFailure;
1038
- const body = await parseJson<{ userCode?: string; user_code?: string }>(req);
1039
- return jsonResponse(denyDevice(store, body.userCode ?? body.user_code ?? ""));
1040
- }
1041
- if (deviceAuthEnabled && (path === "/api/auth/token" || path === "/api/auth/refresh") && req.method === "POST") {
1042
- const body = await parseJson<{ grant_type?: string; device_code?: string; refresh_token?: string; client_id?: string }>(req);
1043
- try {
1044
- if (body.grant_type === "urn:ietf:params:oauth:grant-type:device_code" || body.device_code) {
1045
- return jsonResponse(await exchangeDeviceCode(store, config, body.device_code ?? "", body.client_id));
1046
- }
1047
- return jsonResponse(await exchangeRefreshToken(store, config, body.refresh_token ?? "", body.client_id));
1048
- } catch (error) {
1049
- return tokenError(error);
1050
- }
1051
- }
1052
- if (deviceAuthEnabled && path === "/api/auth/revoke" && req.method === "POST") {
1053
- const body = await parseJson<{ refreshToken?: string; refresh_token?: string }>(req);
1054
- revokeRefreshToken(store, body.refreshToken ?? body.refresh_token ?? "");
1055
- return successResponse();
1056
- }
1057
- if (deviceAuthEnabled && path === "/api/auth/sessions" && req.method === "GET") {
1058
- const auth = await authorize(req, true);
1059
- if (auth instanceof Response) return auth;
1060
- return jsonResponse(listAuthSessions(store, requireUser(auth).id));
1061
- }
1062
- const sessionDelete = /^\/api\/auth\/sessions\/([^/]+)$/.exec(path);
1063
- if (deviceAuthEnabled && sessionDelete && req.method === "DELETE") {
1064
- const auth = await authorize(req, true);
1065
- if (auth instanceof Response) return auth;
1066
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1067
- if (originFailure) return originFailure;
1068
- return revokeAuthSession(store, requireUser(auth).id, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
1069
- }
1070
- if (deviceAuthEnabled && path === "/.well-known/jwks.json" && req.method === "GET") {
1071
- return jsonResponse(await jwks(store));
1072
- }
1073
- if (deviceAuthEnabled && path === "/.well-known/openid-configuration" && req.method === "GET") {
1074
- return jsonResponse(discovery(req, config));
1075
- }
1076
- if (passkeysEnabled && path === "/api/users" && req.method === "GET") {
1077
- const auth = await authorize(req, true);
1078
- if (auth instanceof Response) return auth;
1079
- ensureAdmin(auth);
1080
- return jsonResponse(store.listUsers().map(summarizeUser));
1081
- }
1082
- if (passkeysEnabled && path === "/api/users" && req.method === "POST") {
1083
- const auth = await authorize(req, true);
1084
- if (auth instanceof Response) return auth;
1085
- const actor = ensureAdmin(auth);
1086
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1087
- if (originFailure) return originFailure;
1088
- const body = await parseJson<{ username?: string; role?: WebAppUserRole }>(req);
1089
- const username = assertValidUsername(body.username ?? "");
1090
- if (store.getUserByUsername(username)) {
1091
- return errorResponse(409, "username_exists", "Username already exists");
1092
- }
1093
- const user = createUserRecord({ username, role: sanitizeRole(body.role) });
1094
- store.createUser(user);
1095
- const setupLink = createSetupLink(req, user.id, "invite", actor.id);
1096
- audit(store, { eventType: "user_created", actorUserId: actor.id, targetUserId: user.id, metadata: { role: user.role } });
1097
- return jsonResponse({ user: summarizeUser(store.getUserById(user.id) ?? user), setupLink }, { status: 201 });
1098
- }
1099
- const userRolePatch = /^\/api\/users\/([^/]+)\/role$/.exec(path);
1100
- if (passkeysEnabled && userRolePatch && req.method === "PATCH") {
1101
- const auth = await authorize(req, true);
1102
- if (auth instanceof Response) return auth;
1103
- const actor = ensureAdmin(auth);
1104
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1105
- if (originFailure) return originFailure;
1106
- const userId = decodeURIComponent(userRolePatch[1]!);
1107
- const target = store.getUserById(userId);
1108
- if (!target) return notFound();
1109
- if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner role cannot be changed");
1110
- const body = await parseJson<{ role?: WebAppUserRole }>(req);
1111
- const role = sanitizeRole(body.role);
1112
- store.setUserRole(userId, role, nowIso());
1113
- audit(store, { eventType: "user_role_changed", actorUserId: actor.id, targetUserId: userId, metadata: { role } });
1114
- return jsonResponse(summarizeUser(store.getUserById(userId) ?? target));
1115
- }
1116
- const userReset = /^\/api\/users\/([^/]+)\/reset$/.exec(path);
1117
- if (passkeysEnabled && userReset && req.method === "POST") {
1118
- const auth = await authorize(req, true);
1119
- if (auth instanceof Response) return auth;
1120
- const actor = ensureAdmin(auth);
1121
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1122
- if (originFailure) return originFailure;
1123
- const userId = decodeURIComponent(userReset[1]!);
1124
- const target = store.getUserById(userId);
1125
- if (!target) return notFound();
1126
- if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be reset");
1127
- const timestamp = nowIso();
1128
- store.deletePendingSetupLinksForUser(userId, timestamp);
1129
- store.deletePasskeysForUser(userId);
1130
- store.deleteApiKeysForUser(userId);
1131
- store.revokeRefreshSessionsForUser(userId, timestamp);
1132
- store.incrementUserAuthVersion(userId, timestamp);
1133
- const setupLink = createSetupLink(req, userId, "reset", actor.id);
1134
- audit(store, { eventType: "user_reset", actorUserId: actor.id, targetUserId: userId });
1135
- return jsonResponse({ user: summarizeUser(store.getUserById(userId) ?? target), setupLink });
1136
- }
1137
- const userDelete = /^\/api\/users\/([^/]+)$/.exec(path);
1138
- if (passkeysEnabled && userDelete && req.method === "DELETE") {
1139
- const auth = await authorize(req, true);
1140
- if (auth instanceof Response) return auth;
1141
- const actor = ensureAdmin(auth);
1142
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1143
- if (originFailure) return originFailure;
1144
- const userId = decodeURIComponent(userDelete[1]!);
1145
- const target = store.getUserById(userId);
1146
- if (!target) return notFound();
1147
- if (target.role === "owner") return errorResponse(409, "owner_immutable", "Owner cannot be deleted");
1148
- if (!store.deleteUser(userId)) return notFound();
1149
- audit(store, { eventType: "user_deleted", actorUserId: actor.id, metadata: { deletedUserId: userId, username: target.username } });
1150
- return successResponse();
1151
- }
1152
- if (passkeysEnabled && path === "/api/audit-events" && req.method === "GET") {
1153
- const auth = await authorize(req, true);
1154
- if (auth instanceof Response) return auth;
1155
- ensureAdmin(auth);
1156
- return jsonResponse(store.listAuditEvents(100));
1157
- }
1158
- if (path === "/api/preferences/theme") {
1159
- const auth = await authorize(req, true);
1160
- if (auth instanceof Response) return auth;
1161
- const user = requireUser(auth);
1162
- if (req.method === "GET") {
1163
- return jsonResponse({ theme: store.getThemePreference(user.id) ?? "system" });
1164
- }
1165
- if (req.method === "PUT") {
1166
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1167
- if (originFailure) return originFailure;
1168
- const body = await parseJson<{ theme: ThemePreference }>(req);
1169
- store.setThemePreference(body.theme, user.id);
1170
- return successResponse({ theme: body.theme });
1171
- }
1172
- }
1173
- if (path === "/api/preferences/log-level") {
1174
- const auth = await authorize(req, true);
1175
- if (auth instanceof Response) return auth;
1176
- ensureAdmin(auth);
1177
- if (req.method === "GET") {
1178
- return jsonResponse({ level: store.getLogLevelPreference() ?? config.logLevel, fromEnv: config.logLevelFromEnv });
1179
- }
1180
- if (req.method === "PUT") {
1181
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1182
- if (originFailure) return originFailure;
1183
- if (config.logLevelFromEnv) return errorResponse(409, "log_level_from_env", "Log level is controlled by environment");
1184
- const body = await parseJson<{ level: LogLevelName }>(req);
1185
- if (!LOG_LEVELS.has(body.level)) {
1186
- return errorResponse(400, "invalid_log_level", "Log level must be one of trace, debug, info, warn, error");
1187
- }
1188
- store.setLogLevelPreference(body.level);
1189
- setLogLevel(body.level);
1190
- input.logLevel?.onChange?.(body.level);
1191
- return successResponse({ level: body.level });
1192
- }
1193
- }
1194
- if (path === "/api/server/kill" && req.method === "POST") {
1195
- const auth = await authorize(req, true);
1196
- if (auth instanceof Response) return auth;
1197
- ensureAdmin(auth);
1198
- const originFailure = checkSameOrigin(req, config, auth, "mutations");
1199
- if (originFailure) return originFailure;
1200
- setTimeout(() => process.exit(0), 100);
1201
- return successResponse({ success: true, message: "Server is shutting down" });
1202
- }
1203
- if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
1204
- return htmlResponse(await ensureWebDocument(), req);
1205
- }
1206
- } catch (error) {
1207
- return authErrorResponse(error);
1208
- }
1209
- return undefined;
1210
- }
1211
-
1212
- async function handlePublicRoute(req: Request): Promise<Response | undefined> {
1213
- const url = new URL(req.url);
1214
- if (generatedRoutePaths.has(url.pathname)) {
1215
- const webDocument = await ensureWebDocument();
1216
- const generatedRoute = webDocument.generatedPublicRoutes[url.pathname];
1217
- if (generatedRoute) {
1218
- return handlePublicRouteValue(req, generatedRoute);
1219
- }
1220
- }
1221
- if (!hasOwnPublicRoute(publicRoutes, url.pathname)) {
1222
- return undefined;
1223
- }
1224
- const route = publicRoutes[url.pathname];
1225
- if (!route) {
1226
- return undefined;
1227
- }
1228
- return handlePublicRouteValue(req, route);
1229
- }
1230
-
1231
- async function handlePublicRouteValue(req: Request, route: PublicRouteDefinition): Promise<Response | undefined> {
1232
- const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
1233
- if (!methodName) {
1234
- return withSecurityHeaders(methodNotAllowed());
1235
- }
1236
- const definition = typeof route === "object" && route !== null && !(route instanceof Response) && !(route instanceof Blob) && !(route instanceof ArrayBuffer) && !(route instanceof Uint8Array) && ("GET" in route || "HEAD" in route || "headers" in route)
1237
- ? route
1238
- : undefined;
1239
- const value = definition ? definition[methodName] ?? (methodName === "HEAD" ? definition.GET : undefined) : route as PublicRouteValue;
1240
- if (!value) {
1241
- return withSecurityHeaders(methodNotAllowed());
1242
- }
1243
- const asset = typeof value === "function" ? await value(req) : value;
1244
- if (!asset) {
1245
- return withSecurityHeaders(notFound());
1246
- }
1247
- const response = publicAssetResponse(asset, definition?.headers);
1248
- if (req.method === "HEAD") {
1249
- return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
1250
- }
1251
- return response;
1252
- }
1253
-
1254
- async function handleMatchedRoute(req: Request, matched: NonNullable<ReturnType<typeof matchRoute<TEvent>>>, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
1255
- const handler = matched.route[method(req) ?? "GET"];
1256
- if (!handler) {
1257
- return withSecurityHeaders(methodNotAllowed());
1258
- }
1259
- const routeAuth = matched.route.auth ?? "required";
1260
- const required = requiresAuth(routeAuth);
1261
- const auth = await authorize(req, required);
1262
- if (auth instanceof Response) {
1263
- return withSecurityHeaders(auth);
1264
- }
1265
- try {
1266
- enforceRouteAuth(routeAuth, auth);
1267
- if (matched.route.userParam) {
1268
- const paramValue = matched.params[matched.route.userParam];
1269
- if (!paramValue) {
1270
- throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
1271
- }
1272
- assertUser(auth, paramValue);
1273
- }
1274
- if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
1275
- assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
1276
- }
1277
- } catch (error) {
1278
- return withSecurityHeaders(authErrorResponse(error));
1279
- }
1280
- const current = () => requireUser(auth);
1281
- const userRealtime = {
1282
- publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
1283
- publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
1284
- publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
1285
- publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
1286
- } satisfies UserScopedRealtimePublisher<TEvent>;
1287
- const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
1288
- if (originFailure) {
1289
- return withSecurityHeaders(originFailure);
1290
- }
1291
- try {
1292
- const response = await handler(req, {
1293
- params: matched.params,
1294
- auth,
1295
- user: currentUser(auth),
1296
- requireUser: () => requireUser(auth),
1297
- requireAdmin: () => requireAdmin(auth),
1298
- requireOwner: () => requireOwner(auth),
1299
- assertUser: (userId) => assertUser(auth, userId),
1300
- filterOwned: createFilterOwned(auth),
1301
- requireOwned: createRequireOwned(auth),
1302
- realtime,
1303
- userRealtime,
1304
- server,
1305
- });
1306
- return response ? withSecurityHeaders(response) : undefined;
1307
- } catch (error) {
1308
- return withSecurityHeaders(routeHandlerErrorResponse(error));
1309
- }
1310
- }
60
+ const documentProvider = createWebDocumentProvider(config, input.web, publicRoutes);
61
+ const ensureWebDocument = () => documentProvider.ensure();
62
+ const authentication = createAuthentication({
63
+ store,
64
+ config,
65
+ passkeysEnabled,
66
+ apiKeysEnabled,
67
+ deviceAuthEnabled,
68
+ });
69
+ const frameworkEndpoints = createFrameworkEndpointHandler({
70
+ config,
71
+ store,
72
+ authentication,
73
+ version,
74
+ wsPath,
75
+ passkeysEnabled,
76
+ apiKeysEnabled,
77
+ deviceAuthEnabled,
78
+ configResponse: input.configResponse,
79
+ onLogLevelChange: input.logLevel?.onChange,
80
+ ensureWebDocument,
81
+ });
82
+ const publicRouteDispatcher = createPublicRouteDispatcher({
83
+ publicRoutes,
84
+ generatedRoutePaths: documentProvider.generatedRoutePaths,
85
+ ensureWebDocument,
86
+ });
87
+ const routeDispatcher = createRouteDispatcher({
88
+ config,
89
+ routes,
90
+ authentication,
91
+ realtime,
92
+ });
1311
93
 
1312
94
  async function handleRequest(req: Request, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
1313
95
  const url = new URL(req.url);
1314
- const publicRoute = await handlePublicRoute(req);
96
+ const publicRoute = await publicRouteDispatcher(req);
1315
97
  if (publicRoute) {
1316
98
  return publicRoute;
1317
99
  }
1318
- const matched = matchRoute(routes, url.pathname);
1319
100
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
1320
- const builtIn = await handleBuiltIn(req, server);
101
+ const builtIn = await frameworkEndpoints.handleBuiltIn(req, server);
1321
102
  if (builtIn) {
1322
103
  return secureDynamicResponse(builtIn);
1323
104
  }
1324
- if (!matched) {
105
+ const routeResult = await routeDispatcher.dispatch(req, server);
106
+ if (!routeResult.matched) {
1325
107
  return withSecurityHeaders(notFound());
1326
108
  }
1327
- return handleMatchedRoute(req, matched, server);
109
+ return routeResult.response;
1328
110
  }
1329
- if (matched) {
1330
- return handleMatchedRoute(req, matched, server);
111
+ const routeResult = await routeDispatcher.dispatch(req, server);
112
+ if (routeResult.matched) {
113
+ return routeResult.response;
1331
114
  }
1332
115
  if (!canUseSpaFallback(req)) {
1333
116
  return withSecurityHeaders(notFound());
@@ -1335,106 +118,17 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
1335
118
  return htmlResponse(await ensureWebDocument(), req);
1336
119
  }
1337
120
 
1338
- function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
1339
- const handlerName = socket.data.webappSocketHandler;
1340
- return handlerName ? appWebsockets[handlerName] : undefined;
1341
- }
1342
-
1343
- async function start(): Promise<Server<WebAppWebSocketData>> {
1344
- const webDocument = await ensureWebDocument();
1345
- const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
1346
- const publicRouteHandlers = Object.fromEntries([
1347
- ...Object.keys(webDocument.generatedPublicRoutes),
1348
- ...Object.keys(publicRoutes),
1349
- ].map((path) => [path, dynamicHandler]));
1350
- const spaFallbackRoute = {
1351
- GET: dynamicHandler,
1352
- HEAD: dynamicHandler,
1353
- POST: dynamicHandler,
1354
- PUT: dynamicHandler,
1355
- PATCH: dynamicHandler,
1356
- DELETE: dynamicHandler,
1357
- OPTIONS: dynamicHandler,
1358
- };
1359
- // Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
1360
- // Wrapping it in a handler or Response, or adding route-level headers, serves
1361
- // untransformed module paths and breaks generated document routes.
1362
- const spaDocumentRoute = webDocument.bundle ? {
1363
- ...spaFallbackRoute,
1364
- GET: webDocument.bundle as never,
1365
- HEAD: webDocument.bundle as never,
1366
- } : spaFallbackRoute;
1367
- const entryRoute = webDocument.bundle ? { [webDocument.entryPublicPath]: webDocument.bundle as never } : {};
1368
- const server = Bun.serve<WebAppWebSocketData>({
1369
- hostname: config.host,
1370
- port: config.port,
1371
- routes: {
1372
- ...publicRouteHandlers,
1373
- ...entryRoute,
1374
- "/api/*": dynamicHandler,
1375
- "/.well-known/*": dynamicHandler,
1376
- "/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
1377
- "/setup": spaDocumentRoute,
1378
- "/*": spaDocumentRoute,
1379
- },
1380
- websocket: {
1381
- open(socket) {
1382
- const handler = customHandler(socket);
1383
- if (handler?.open) {
1384
- handler.open(socket);
1385
- return;
1386
- }
1387
- realtime.add(socket);
1388
- },
1389
- message(socket, message) {
1390
- const handler = customHandler(socket);
1391
- if (handler?.message) {
1392
- handler.message(socket, message);
1393
- return;
1394
- }
1395
- if (message === "ping") {
1396
- socket.send(JSON.stringify({ type: "pong" }));
1397
- }
1398
- },
1399
- close(socket, code, reason) {
1400
- const handler = customHandler(socket);
1401
- if (handler?.close) {
1402
- handler.close(socket, code, reason);
1403
- return;
1404
- }
1405
- realtime.remove(socket);
1406
- },
1407
- drain(socket) {
1408
- customHandler(socket)?.drain?.(socket);
1409
- },
1410
- },
1411
- development: config.development,
1412
- });
1413
- const stop = server.stop.bind(server);
1414
- server.stop = ((closeActiveConnections?: boolean) => {
1415
- stop(closeActiveConnections);
1416
- cleanupDocumentCacheDir(webDocument.cacheDir);
1417
- }) as typeof server.stop;
1418
- log.info(`${config.appName} server running`, { url: String(server.url) });
1419
- return server;
1420
- }
1421
-
1422
- async function runFromCli(argv = Bun.argv.slice(2)): Promise<void> {
1423
- const command = argv[0] ?? "serve";
1424
- if (command === "serve") {
1425
- await start();
1426
- return await new Promise(() => undefined);
1427
- }
1428
- if (command === "version") {
1429
- console.log(version);
1430
- return;
1431
- }
1432
- if (command === "config") {
1433
- console.log(JSON.stringify(safeRuntimeConfig(config), null, 2));
1434
- return;
1435
- }
1436
- throw new Error(`Unknown command: ${command}`);
1437
- }
121
+ const lifecycle = createServerLifecycle({
122
+ config,
123
+ version,
124
+ deviceAuthEnabled,
125
+ publicRoutes,
126
+ appWebsockets,
127
+ realtime,
128
+ ensureWebDocument,
129
+ documentProvider,
130
+ handleRequest,
131
+ });
1438
132
 
1439
- return { config, store, realtime, handleRequest, start, runFromCli };
133
+ return { config, store, realtime, handleRequest, ...lifecycle };
1440
134
  }