@pablozaiden/webapp 0.5.7 → 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.
- package/README.md +11 -3
- package/docs/auth-validation.md +11 -0
- package/docs/auth.md +25 -0
- package/docs/cli.md +73 -3
- package/docs/deployment.md +84 -7
- package/docs/getting-started.md +69 -6
- package/docs/github-actions.md +39 -0
- package/docs/release.md +5 -5
- package/docs/server.md +34 -4
- package/docs/settings.md +5 -0
- package/docs/sidebar.md +2 -0
- package/docs/ui-guidelines.md +12 -4
- package/package.json +2 -4
- package/src/build/build-binary.ts +67 -24
- package/src/cli/api-command.ts +53 -16
- package/src/cli/credentials.ts +275 -4
- package/src/cli/device-auth.ts +83 -22
- package/src/cli/environment-auth.ts +57 -0
- package/src/cli/index.ts +1 -0
- package/src/package-resolution.ts +34 -0
- package/src/server/auth/device-auth.ts +2 -2
- package/src/server/auth/passkeys.ts +16 -16
- package/src/server/auth/request-origin.ts +97 -16
- package/src/server/authentication.ts +265 -0
- package/src/server/create-web-app-server.ts +85 -1391
- package/src/server/framework-endpoints.ts +451 -0
- package/src/server/public-route-dispatch.ts +85 -0
- package/src/server/request-schemas.ts +144 -0
- package/src/server/responses.ts +69 -3
- package/src/server/route-dispatch.ts +109 -0
- package/src/server/runtime-config.ts +67 -0
- package/src/server/same-origin.ts +1 -1
- package/src/server/server-lifecycle.ts +138 -0
- package/src/server/server-types.ts +87 -0
- package/src/server/web-document.ts +532 -0
- package/src/web/WebAppRoot.tsx +69 -1132
- package/src/web/api-client.ts +14 -4
- package/src/web/app-shell.tsx +198 -0
- package/src/web/auth-screens.tsx +186 -0
- package/src/web/components/index.tsx +10 -3
- package/src/web/index.ts +1 -0
- package/src/web/mobile-hooks.ts +194 -0
- package/src/web/mobile.ts +12 -0
- package/src/web/root-types.ts +61 -0
- package/src/web/routing.ts +61 -0
- package/src/web/settings/account-section.tsx +52 -0
- package/src/web/settings/resource-state.tsx +17 -0
- package/src/web/settings/security-section.tsx +119 -0
- package/src/web/settings/sessions-section.tsx +66 -0
- package/src/web/settings/settings-view.tsx +96 -0
- package/src/web/settings/shutdown-section.tsx +95 -0
- package/src/web/settings/user-management.tsx +123 -0
- package/src/web/settings-view.tsx +3 -0
- package/src/web/sidebar-state.ts +108 -0
- package/src/web/sidebar-tree.tsx +89 -0
- package/src/web/styles.css +78 -61
package/src/server/responses.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
97
|
+
value = JSON.parse(text);
|
|
33
98
|
} catch {
|
|
34
|
-
throw new
|
|
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
|
|
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
|
+
}
|