@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,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import type { WebAppErrorResponse } from "../contracts";
2
3
 
3
4
  export function jsonResponse<T>(data: T, init: ResponseInit = {}): Response {
@@ -27,12 +28,77 @@ export function methodNotAllowed(): Response {
27
28
  return errorResponse(405, "method_not_allowed", "Method not allowed");
28
29
  }
29
30
 
30
- export async function parseJson<T>(req: Request): Promise<T> {
31
+ export class InvalidJsonError extends Error {
32
+ readonly code = "invalid_json";
33
+ readonly status = 400;
34
+
35
+ constructor() {
36
+ super("Request body must be valid JSON");
37
+ this.name = "InvalidJsonError";
38
+ }
39
+ }
40
+
41
+ export interface RequestBodyValidationIssue {
42
+ path: Array<string | number>;
43
+ code: string;
44
+ message: string;
45
+ }
46
+
47
+ export class InvalidRequestBodyError extends Error {
48
+ readonly code = "invalid_request_body";
49
+ readonly status = 400;
50
+ readonly details: RequestBodyValidationIssue[];
51
+
52
+ constructor(error: z.ZodError) {
53
+ super("Request body failed validation");
54
+ this.name = "InvalidRequestBodyError";
55
+ this.details = error.issues.map((issue) => ({
56
+ path: issue.path.map((segment) => typeof segment === "symbol" ? String(segment) : segment),
57
+ code: issue.code,
58
+ message: issue.message,
59
+ }));
60
+ }
61
+ }
62
+
63
+ export function requestBodyErrorResponse(error: unknown): Response | undefined {
64
+ if (error instanceof InvalidJsonError || error instanceof InvalidRequestBodyError) {
65
+ return errorResponse(error.status, error.code, error.message, "details" in error ? error.details : undefined);
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function validateJson<TSchema extends z.ZodTypeAny>(value: unknown, schema: TSchema): z.infer<TSchema> {
71
+ const result = schema.safeParse(value);
72
+ if (!result.success) {
73
+ throw new InvalidRequestBodyError(result.error);
74
+ }
75
+ return result.data;
76
+ }
77
+
78
+ export async function parseUnknownJson(req: Request): Promise<unknown> {
79
+ try {
80
+ return await req.json();
81
+ } catch {
82
+ throw new InvalidJsonError();
83
+ }
84
+ }
85
+
86
+ export async function parseJson<TSchema extends z.ZodTypeAny>(req: Request, schema: TSchema): Promise<z.infer<TSchema>> {
87
+ return validateJson(await parseUnknownJson(req), schema);
88
+ }
89
+
90
+ export async function parseOptionalJson<TSchema extends z.ZodTypeAny>(req: Request, schema: TSchema): Promise<z.infer<TSchema> | undefined> {
91
+ const text = await req.text();
92
+ if (text.length === 0) {
93
+ return undefined;
94
+ }
95
+ let value: unknown;
31
96
  try {
32
- return await req.json() as T;
97
+ value = JSON.parse(text);
33
98
  } catch {
34
- throw new Error("Request body must be valid JSON");
99
+ throw new InvalidJsonError();
35
100
  }
101
+ return validateJson(value, schema);
36
102
  }
37
103
 
38
104
  export function applySecurityHeaders(headers: Headers): Headers {
@@ -0,0 +1,109 @@
1
+ import type { Server } from "bun";
2
+ import { assertScopes } from "./auth/api-keys";
3
+ import { AuthError } from "./auth/types";
4
+ import { authErrorResponse, enforceRouteAuth, requiresAuth, scopesFromBearer, type Authentication } from "./authentication";
5
+ import type { RuntimeConfig } from "./runtime-config";
6
+ import { createLogger } from "./logger";
7
+ import { checkSameOrigin } from "./same-origin";
8
+ import type { RealtimeBus } from "./realtime/bus";
9
+ import { matchRoute, type HttpMethod, type RouteTable, type UserScopedRealtimePublisher } from "./routes";
10
+ import type { WebAppWebSocketData } from "./server-types";
11
+ import { errorResponse, requestBodyErrorResponse, withSecurityHeaders, methodNotAllowed } from "./responses";
12
+
13
+ export interface RouteDispatchResult {
14
+ matched: boolean;
15
+ response?: Response;
16
+ }
17
+
18
+ export interface RouteDispatcherDependencies<TEvent = unknown> {
19
+ config: RuntimeConfig;
20
+ routes: RouteTable<TEvent>;
21
+ authentication: Authentication;
22
+ realtime: RealtimeBus<TEvent>;
23
+ }
24
+
25
+ const log = createLogger("webapp:routes");
26
+
27
+ function method(req: Request): HttpMethod | undefined {
28
+ const value = req.method.toUpperCase();
29
+ return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE" ? value : undefined;
30
+ }
31
+
32
+ function routeHandlerErrorResponse(error: unknown): Response {
33
+ const requestBodyFailure = requestBodyErrorResponse(error);
34
+ if (requestBodyFailure) {
35
+ return requestBodyFailure;
36
+ }
37
+ if (error instanceof AuthError) {
38
+ return errorResponse(error.status, error.code, error.message);
39
+ }
40
+ log.error("Unhandled route handler error", { error: error instanceof Error ? error.message : String(error) });
41
+ return errorResponse(500, "request_failed", "Request failed");
42
+ }
43
+
44
+ export function createRouteDispatcher<TEvent = unknown>(dependencies: RouteDispatcherDependencies<TEvent>) {
45
+ const { config, routes, authentication, realtime } = dependencies;
46
+
47
+ return {
48
+ async dispatch(req: Request, server?: Server<WebAppWebSocketData>): Promise<RouteDispatchResult> {
49
+ const matched = matchRoute(routes, new URL(req.url).pathname);
50
+ if (!matched) {
51
+ return { matched: false };
52
+ }
53
+ const handler = matched.route[method(req) ?? "GET"];
54
+ if (!handler) {
55
+ return { matched: true, response: withSecurityHeaders(methodNotAllowed()) };
56
+ }
57
+ const routeAuth = matched.route.auth ?? "required";
58
+ const auth = await authentication.authorize(req, requiresAuth(routeAuth));
59
+ if (auth instanceof Response) {
60
+ return { matched: true, response: withSecurityHeaders(auth) };
61
+ }
62
+ try {
63
+ enforceRouteAuth(routeAuth, auth, authentication);
64
+ if (matched.route.userParam) {
65
+ const paramValue = matched.params[matched.route.userParam];
66
+ if (!paramValue) {
67
+ throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
68
+ }
69
+ authentication.assertUser(auth, paramValue);
70
+ }
71
+ if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
72
+ assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
73
+ }
74
+ } catch (error) {
75
+ return { matched: true, response: withSecurityHeaders(authErrorResponse(error)) };
76
+ }
77
+ const current = () => authentication.requireUser(auth);
78
+ const userRealtime = {
79
+ publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
80
+ publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
81
+ publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
82
+ publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
83
+ } satisfies UserScopedRealtimePublisher<TEvent>;
84
+ const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
85
+ if (originFailure) {
86
+ return { matched: true, response: withSecurityHeaders(originFailure) };
87
+ }
88
+ try {
89
+ const response = await handler(req, {
90
+ params: matched.params,
91
+ auth,
92
+ user: authentication.currentUser(auth),
93
+ requireUser: () => authentication.requireUser(auth),
94
+ requireAdmin: () => authentication.requireAdmin(auth),
95
+ requireOwner: () => authentication.requireOwner(auth),
96
+ assertUser: (userId) => authentication.assertUser(auth, userId),
97
+ filterOwned: authentication.createFilterOwned(auth),
98
+ requireOwned: authentication.createRequireOwned(auth),
99
+ realtime,
100
+ userRealtime,
101
+ server,
102
+ });
103
+ return { matched: true, response: response ? withSecurityHeaders(response) : undefined };
104
+ } catch (error) {
105
+ return { matched: true, response: withSecurityHeaders(routeHandlerErrorResponse(error)) };
106
+ }
107
+ },
108
+ };
109
+ }
@@ -1,5 +1,15 @@
1
1
  import type { LogLevelName } from "../contracts";
2
2
 
3
+ export const TRUST_PROXY_HEADERS = ["proto", "host", "prefix"] as const;
4
+ export type TrustProxyHeader = typeof TRUST_PROXY_HEADERS[number];
5
+ export type TrustProxyChain = "first" | "last";
6
+
7
+ export interface TrustProxyConfig {
8
+ enabled: boolean;
9
+ headers: readonly TrustProxyHeader[];
10
+ chain: TrustProxyChain;
11
+ }
12
+
3
13
  export interface RuntimeConfig {
4
14
  appName: string;
5
15
  envPrefix: string;
@@ -12,6 +22,7 @@ export interface RuntimeConfig {
12
22
  sameOriginDisabled: boolean;
13
23
  publicBaseUrl?: string;
14
24
  authIssuer?: string;
25
+ trustProxy: TrustProxyConfig;
15
26
  development: false | { hmr: true; console: true };
16
27
  }
17
28
 
@@ -59,6 +70,50 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
59
70
  return raw as LogLevelName;
60
71
  }
61
72
 
73
+ function parseBoolean(raw: string | undefined, fallback: boolean, name: string): boolean {
74
+ if (raw === undefined) {
75
+ return fallback;
76
+ }
77
+ const normalized = raw.toLowerCase();
78
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
79
+ return true;
80
+ }
81
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
82
+ return false;
83
+ }
84
+ throw new Error(`${name} must be true or false; received "${raw}"`);
85
+ }
86
+
87
+ function parseTrustProxyHeaders(raw: string | undefined, enabled: boolean, name: string): TrustProxyHeader[] {
88
+ if (raw === undefined) {
89
+ return enabled ? [...TRUST_PROXY_HEADERS] : [];
90
+ }
91
+ const values = raw.split(",").map((value) => value.trim().toLowerCase());
92
+ if (values.length === 0 || values.some((value) => !value)) {
93
+ throw new Error(`${name} must be a comma-separated list of proto, host, and prefix; received "${raw}"`);
94
+ }
95
+ const headers: TrustProxyHeader[] = [];
96
+ for (const value of values) {
97
+ const header = TRUST_PROXY_HEADERS.find((candidate) => candidate === value);
98
+ if (!header) {
99
+ throw new Error(`${name} must contain only proto, host, and prefix; received "${raw}"`);
100
+ }
101
+ if (headers.includes(header)) {
102
+ throw new Error(`${name} must not contain duplicate values; received "${raw}"`);
103
+ }
104
+ headers.push(header);
105
+ }
106
+ return headers;
107
+ }
108
+
109
+ function parseTrustProxyChain(raw: string | undefined, name: string): TrustProxyChain {
110
+ const value = raw?.toLowerCase() ?? "first";
111
+ if (value === "first" || value === "last") {
112
+ return value;
113
+ }
114
+ throw new Error(`${name} must be first or last; received "${raw}"`);
115
+ }
116
+
62
117
  export function readRuntimeConfig(input: {
63
118
  appName: string;
64
119
  envPrefix: string;
@@ -67,6 +122,12 @@ export function readRuntimeConfig(input: {
67
122
  const envPrefix = assertEnvPrefix(input.envPrefix);
68
123
  const logLevelRaw = readEnv(envPrefix, "LOG_LEVEL");
69
124
  const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? "info", envName(envPrefix, "LOG_LEVEL"));
125
+ const trustProxyEnabled = parseBoolean(readEnv(envPrefix, "TRUST_PROXY"), false, envName(envPrefix, "TRUST_PROXY"));
126
+ const trustProxy = {
127
+ enabled: trustProxyEnabled,
128
+ headers: parseTrustProxyHeaders(readEnv(envPrefix, "TRUST_PROXY_HEADERS"), trustProxyEnabled, envName(envPrefix, "TRUST_PROXY_HEADERS")),
129
+ chain: parseTrustProxyChain(readEnv(envPrefix, "TRUST_PROXY_CHAIN"), envName(envPrefix, "TRUST_PROXY_CHAIN")),
130
+ } satisfies TrustProxyConfig;
70
131
  return {
71
132
  appName: input.appName,
72
133
  envPrefix,
@@ -79,6 +140,7 @@ export function readRuntimeConfig(input: {
79
140
  sameOriginDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_SAME_ORIGIN_CHECK")),
80
141
  publicBaseUrl: readEnv(envPrefix, "PUBLIC_BASE_URL"),
81
142
  authIssuer: readEnv(envPrefix, "AUTH_ISSUER"),
143
+ trustProxy,
82
144
  development: process.env["NODE_ENV"] === "production" ? false : { hmr: true, console: true },
83
145
  };
84
146
  }
@@ -96,6 +158,11 @@ export function safeRuntimeConfig(config: RuntimeConfig): Record<string, unknown
96
158
  sameOriginDisabled: config.sameOriginDisabled,
97
159
  publicBaseUrl: config.publicBaseUrl,
98
160
  authIssuer: config.authIssuer,
161
+ trustProxy: {
162
+ enabled: config.trustProxy.enabled,
163
+ headers: [...config.trustProxy.headers],
164
+ chain: config.trustProxy.chain,
165
+ },
99
166
  production: config.development === false,
100
167
  };
101
168
  }
@@ -18,7 +18,7 @@ export function checkSameOrigin(req: Request, config: RuntimeConfig, auth: Authe
18
18
  if (mode !== "always" && (auth.kind === "api-key" || auth.kind === "bearer")) {
19
19
  return undefined;
20
20
  }
21
- const expectedOrigin = getRequestOriginInfo(req, config.publicBaseUrl).origin;
21
+ const expectedOrigin = getRequestOriginInfo(req, config).origin;
22
22
  const origin = req.headers.get("origin");
23
23
  if (origin) {
24
24
  return origin === expectedOrigin ? undefined : errorResponse(403, "same_origin_required", "Request origin is not allowed");
@@ -0,0 +1,138 @@
1
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
+ import { createLogger } from "./logger";
3
+ import type { RealtimeBus } from "./realtime/bus";
4
+ import type { WebAppWebSocketData, WebAppServerConfig } from "./server-types";
5
+ import type { RuntimeConfig } from "./runtime-config";
6
+ import { safeRuntimeConfig } from "./runtime-config";
7
+ import type { WebDocument, WebDocumentProvider } from "./web-document";
8
+
9
+ export interface ServerLifecycleDependencies<TEvent = unknown> {
10
+ config: RuntimeConfig;
11
+ version: string;
12
+ deviceAuthEnabled: boolean;
13
+ publicRoutes: Readonly<Record<string, unknown>>;
14
+ appWebsockets: NonNullable<WebAppServerConfig["websockets"]>;
15
+ realtime: RealtimeBus<TEvent>;
16
+ ensureWebDocument: () => Promise<WebDocument>;
17
+ documentProvider: WebDocumentProvider;
18
+ handleRequest: (req: Request, server?: Server<WebAppWebSocketData>) => Promise<Response | undefined>;
19
+ }
20
+
21
+ const log = createLogger("webapp:server");
22
+
23
+ export function createServerLifecycle<TEvent = unknown>(dependencies: ServerLifecycleDependencies<TEvent>) {
24
+ const {
25
+ config,
26
+ version,
27
+ deviceAuthEnabled,
28
+ publicRoutes,
29
+ appWebsockets,
30
+ realtime,
31
+ ensureWebDocument,
32
+ documentProvider,
33
+ handleRequest,
34
+ } = dependencies;
35
+
36
+ function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
37
+ const handlerName = socket.data.webappSocketHandler;
38
+ return handlerName ? appWebsockets[handlerName] : undefined;
39
+ }
40
+
41
+ async function start(): Promise<Server<WebAppWebSocketData>> {
42
+ const webDocument = await ensureWebDocument();
43
+ const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
44
+ const publicRouteHandlers = Object.fromEntries([
45
+ ...Object.keys(webDocument.generatedPublicRoutes),
46
+ ...Object.keys(publicRoutes),
47
+ ].map((path) => [path, dynamicHandler]));
48
+ const spaFallbackRoute = {
49
+ GET: dynamicHandler,
50
+ HEAD: dynamicHandler,
51
+ POST: dynamicHandler,
52
+ PUT: dynamicHandler,
53
+ PATCH: dynamicHandler,
54
+ DELETE: dynamicHandler,
55
+ OPTIONS: dynamicHandler,
56
+ };
57
+ // Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
58
+ // Wrapping it in a handler or Response, or adding route-level headers, serves
59
+ // untransformed module paths and breaks generated document routes.
60
+ const spaDocumentRoute = webDocument.bundle ? {
61
+ ...spaFallbackRoute,
62
+ GET: webDocument.bundle as never,
63
+ HEAD: webDocument.bundle as never,
64
+ } : spaFallbackRoute;
65
+ const entryRoute = webDocument.bundle ? { [webDocument.entryPublicPath]: webDocument.bundle as never } : {};
66
+ const server = Bun.serve<WebAppWebSocketData>({
67
+ hostname: config.host,
68
+ port: config.port,
69
+ routes: {
70
+ ...publicRouteHandlers,
71
+ ...entryRoute,
72
+ "/api/*": dynamicHandler,
73
+ "/.well-known/*": dynamicHandler,
74
+ "/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
75
+ "/setup": spaDocumentRoute,
76
+ "/*": spaDocumentRoute,
77
+ },
78
+ websocket: {
79
+ open(socket) {
80
+ const handler = customHandler(socket);
81
+ if (handler?.open) {
82
+ handler.open(socket);
83
+ return;
84
+ }
85
+ realtime.add(socket);
86
+ },
87
+ message(socket, message) {
88
+ const handler = customHandler(socket);
89
+ if (handler?.message) {
90
+ handler.message(socket, message);
91
+ return;
92
+ }
93
+ if (message === "ping") {
94
+ socket.send(JSON.stringify({ type: "pong" }));
95
+ }
96
+ },
97
+ close(socket, code, reason) {
98
+ const handler = customHandler(socket);
99
+ if (handler?.close) {
100
+ handler.close(socket, code, reason);
101
+ return;
102
+ }
103
+ realtime.remove(socket);
104
+ },
105
+ drain(socket) {
106
+ customHandler(socket)?.drain?.(socket);
107
+ },
108
+ },
109
+ development: config.development,
110
+ });
111
+ const stop = server.stop.bind(server);
112
+ server.stop = ((closeActiveConnections?: boolean) => {
113
+ stop(closeActiveConnections);
114
+ documentProvider.dispose(webDocument);
115
+ }) as typeof server.stop;
116
+ log.info(`${config.appName} server running`, { url: String(server.url) });
117
+ return server;
118
+ }
119
+
120
+ async function runFromCli(argv = Bun.argv.slice(2)): Promise<void> {
121
+ const command = argv[0] ?? "serve";
122
+ if (command === "serve") {
123
+ await start();
124
+ return await new Promise(() => undefined);
125
+ }
126
+ if (command === "version") {
127
+ console.log(version);
128
+ return;
129
+ }
130
+ if (command === "config") {
131
+ console.log(JSON.stringify(safeRuntimeConfig(config), null, 2));
132
+ return;
133
+ }
134
+ throw new Error(`Unknown command: ${command}`);
135
+ }
136
+
137
+ return { start, runFromCli };
138
+ }
@@ -0,0 +1,87 @@
1
+ import type { Server, WebSocketHandler } from "bun";
2
+ import type { LogLevelName, WebAppConfigResponse } from "../contracts";
3
+ import type { RealtimeBus, WebSocketData } from "./realtime/bus";
4
+ import type { WebAppStore } from "./auth/store";
5
+ import type { RuntimeConfig } from "./runtime-config";
6
+ import type { RouteTable } from "./routes";
7
+
8
+ export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
9
+
10
+ export type WebAppWebSocketData = WebSocketData & {
11
+ webappSocketHandler?: string;
12
+ [key: string]: unknown;
13
+ };
14
+
15
+ export type PublicRouteAsset = Response | Blob | ArrayBuffer | Uint8Array | string;
16
+ export type PublicRouteHandler = (req: Request) => PublicRouteAsset | undefined | Promise<PublicRouteAsset | undefined>;
17
+ export type PublicRouteValue = PublicRouteAsset | PublicRouteHandler;
18
+ export type PublicRouteDefinition =
19
+ | PublicRouteValue
20
+ | {
21
+ GET?: PublicRouteValue;
22
+ HEAD?: PublicRouteValue;
23
+ headers?: HeadersInit;
24
+ };
25
+
26
+ export interface WebAppServerConfig<TEvent = unknown> {
27
+ appName: string;
28
+ envPrefix: string;
29
+ web?: WebAppDocumentConfig;
30
+ version?: string;
31
+ store?: WebAppStore;
32
+ routes?: RouteTable<TEvent>;
33
+ publicRoutes?: Record<string, PublicRouteDefinition>;
34
+ websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
35
+ auth?: {
36
+ passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
37
+ apiKeys?: boolean;
38
+ deviceAuth?: boolean;
39
+ };
40
+ realtime?: {
41
+ path?: string;
42
+ };
43
+ logLevel?: {
44
+ onChange?: (level: LogLevelName) => void;
45
+ };
46
+ configResponse?: (req: Request, base: Readonly<WebAppConfigResponse>) => Record<string, unknown>;
47
+ }
48
+
49
+ export interface WebAppServer<TEvent = unknown> {
50
+ config: RuntimeConfig;
51
+ store: WebAppStore;
52
+ realtime: RealtimeBus<TEvent>;
53
+ handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined>;
54
+ start(): Promise<Server<WebSocketData>>;
55
+ runFromCli(argv?: string[]): Promise<void>;
56
+ }
57
+
58
+ export interface WebAppDocumentConfig {
59
+ entry?: string | URL;
60
+ title?: string;
61
+ shortName?: string;
62
+ lang?: string;
63
+ pwa?: boolean | WebAppPwaConfig;
64
+ themeColor?: string;
65
+ backgroundColor?: string;
66
+ icons?: WebAppIconsConfig;
67
+ }
68
+
69
+ export interface WebAppPwaConfig {
70
+ enabled?: boolean;
71
+ display?: "standalone" | "fullscreen" | "minimal-ui" | "browser";
72
+ startUrl?: string;
73
+ scope?: string;
74
+ }
75
+
76
+ export interface WebAppIconConfig {
77
+ src: string | URL;
78
+ sizes?: string;
79
+ type?: string;
80
+ purpose?: string;
81
+ }
82
+
83
+ export interface WebAppIconsConfig {
84
+ favicon?: string | URL | WebAppIconConfig;
85
+ appleTouch?: string | URL | WebAppIconConfig;
86
+ manifest?: WebAppIconConfig[];
87
+ }