@pablozaiden/webapp 0.0.1

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 (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/docs/auth-validation.md +34 -0
  4. package/docs/auth.md +35 -0
  5. package/docs/deployment.md +49 -0
  6. package/docs/getting-started.md +62 -0
  7. package/docs/github-actions.md +421 -0
  8. package/docs/realtime.md +61 -0
  9. package/docs/release.md +64 -0
  10. package/docs/server.md +45 -0
  11. package/docs/settings.md +42 -0
  12. package/docs/sidebar.md +80 -0
  13. package/docs/ui-guidelines.md +55 -0
  14. package/package.json +60 -0
  15. package/src/build/build-binary.ts +42 -0
  16. package/src/build/index.ts +1 -0
  17. package/src/contracts/index.ts +86 -0
  18. package/src/server/auth/api-keys.ts +61 -0
  19. package/src/server/auth/crypto.ts +34 -0
  20. package/src/server/auth/device-auth.ts +324 -0
  21. package/src/server/auth/passkeys.ts +280 -0
  22. package/src/server/auth/request-origin.ts +33 -0
  23. package/src/server/auth/sqlite-store.ts +301 -0
  24. package/src/server/auth/store.ts +91 -0
  25. package/src/server/auth/types.ts +25 -0
  26. package/src/server/create-web-app-server.ts +447 -0
  27. package/src/server/index.ts +7 -0
  28. package/src/server/logger.ts +44 -0
  29. package/src/server/realtime/bus.ts +104 -0
  30. package/src/server/responses.ts +54 -0
  31. package/src/server/routes.ts +67 -0
  32. package/src/server/runtime-config.ts +101 -0
  33. package/src/server/same-origin.ts +35 -0
  34. package/src/types.d.ts +11 -0
  35. package/src/web/WebAppRoot.tsx +706 -0
  36. package/src/web/components/index.tsx +471 -0
  37. package/src/web/index.ts +5 -0
  38. package/src/web/realtime/useRealtime.ts +189 -0
  39. package/src/web/sidebar/types.ts +45 -0
  40. package/src/web/styles.css +1295 -0
@@ -0,0 +1,447 @@
1
+ import type { Server } from "bun";
2
+ import type { ApiKeySummary, LogLevelName, ThemePreference, WebAppConfigResponse } from "../contracts";
3
+ import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
4
+ import {
5
+ approveDevice,
6
+ createDeviceAuthorization,
7
+ denyDevice,
8
+ discovery,
9
+ exchangeDeviceCode,
10
+ exchangeRefreshToken,
11
+ getDeviceVerificationDetails,
12
+ jwks,
13
+ listAuthSessions,
14
+ revokeAuthSession,
15
+ revokeRefreshToken,
16
+ verifyAccessToken,
17
+ } from "./auth/device-auth";
18
+ import {
19
+ beginAuthentication,
20
+ beginRegistration,
21
+ completeAuthentication,
22
+ completeRegistration,
23
+ deletePasskey,
24
+ hasPasskeySession,
25
+ isPasskeyAuthRequired,
26
+ logoutHeaders,
27
+ passkeyStatus,
28
+ } from "./auth/passkeys";
29
+ import { sqliteWebAppStore } from "./auth/sqlite-store";
30
+ import type { WebAppStore } from "./auth/store";
31
+ import { AuthError, type AuthenticatedRequestState } from "./auth/types";
32
+ import { createLogger, setLogLevel } from "./logger";
33
+ import { createRealtimeBus, type RealtimeBus, type WebSocketData } from "./realtime/bus";
34
+ import { readRuntimeConfig, safeRuntimeConfig, type RuntimeConfig } from "./runtime-config";
35
+ import { matchRoute, type RouteTable } from "./routes";
36
+ import { checkSameOrigin } from "./same-origin";
37
+ import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse, withSecurityHeaders } from "./responses";
38
+
39
+ export interface WebAppServerConfig<TEvent = unknown> {
40
+ appName: string;
41
+ envPrefix: string;
42
+ index: unknown;
43
+ version?: string;
44
+ store?: WebAppStore;
45
+ routes?: RouteTable<TEvent>;
46
+ auth?: {
47
+ passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
48
+ apiKeys?: boolean;
49
+ deviceAuth?: boolean;
50
+ };
51
+ realtime?: {
52
+ path?: string;
53
+ };
54
+ }
55
+
56
+ export interface WebAppServer<TEvent = unknown> {
57
+ config: RuntimeConfig;
58
+ store: WebAppStore;
59
+ realtime: RealtimeBus<TEvent>;
60
+ handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined>;
61
+ start(): Server<WebSocketData>;
62
+ runFromCli(argv?: string[]): Promise<void>;
63
+ }
64
+
65
+ const log = createLogger("webapp:server");
66
+
67
+ function bearerToken(req: Request): string | undefined {
68
+ const header = req.headers.get("authorization")?.trim();
69
+ if (!header) {
70
+ return undefined;
71
+ }
72
+ const [scheme, token] = header.split(/\s+/, 2);
73
+ return scheme?.toLowerCase() === "bearer" ? token : undefined;
74
+ }
75
+
76
+ function method(req: Request): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | undefined {
77
+ const value = req.method.toUpperCase();
78
+ return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE" ? value : undefined;
79
+ }
80
+
81
+ function tokenError(error: unknown): Response {
82
+ if (error instanceof AuthError) {
83
+ return jsonResponse({ error: error.code, error_description: error.message }, { status: error.status });
84
+ }
85
+ return jsonResponse({ error: "server_error", error_description: "An unexpected auth error occurred" }, { status: 500 });
86
+ }
87
+
88
+ function authErrorResponse(error: unknown): Response {
89
+ if (error instanceof AuthError) {
90
+ return errorResponse(error.status, error.code, error.message);
91
+ }
92
+ if (error instanceof Error) {
93
+ return errorResponse(400, "request_failed", error.message);
94
+ }
95
+ return errorResponse(500, "request_failed", "Request failed");
96
+ }
97
+
98
+ function addHeaders(response: Response, headers: Headers): Response {
99
+ for (const [name, value] of headers) {
100
+ response.headers.append(name, value);
101
+ }
102
+ return response;
103
+ }
104
+
105
+ function htmlResponse(index: unknown): Response {
106
+ if (index instanceof Response) {
107
+ return withSecurityHeaders(index);
108
+ }
109
+ if (typeof index === "string") {
110
+ return withSecurityHeaders(new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } }));
111
+ }
112
+ if (index instanceof Blob) {
113
+ return withSecurityHeaders(new Response(index));
114
+ }
115
+ return index as Response;
116
+ }
117
+
118
+ function canRespondWithIndex(index: unknown): boolean {
119
+ return index instanceof Response || typeof index === "string" || index instanceof Blob;
120
+ }
121
+
122
+ function scopesFromBearer(claims: { scope: string }): string[] {
123
+ return claims.scope.split(/\s+/).filter(Boolean);
124
+ }
125
+
126
+ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<TEvent>): WebAppServer<TEvent> {
127
+ const config = readRuntimeConfig({ appName: input.appName, envPrefix: input.envPrefix });
128
+ const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
129
+ store.initialize();
130
+ const savedLogLevel = store.getLogLevelPreference();
131
+ setLogLevel(config.logLevelFromEnv ? config.logLevel : savedLogLevel ?? config.logLevel);
132
+ const realtime = createRealtimeBus<TEvent>();
133
+ const version = input.version ?? "0.0.0-development";
134
+ const wsPath = input.realtime?.path ?? "/api/ws";
135
+ const routes = input.routes ?? {};
136
+ const passkeysEnabled = input.auth?.passkeys !== false;
137
+ const apiKeysEnabled = input.auth?.apiKeys ?? false;
138
+ const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
139
+
140
+ async function authorize(req: Request, required: boolean): Promise<AuthenticatedRequestState | Response> {
141
+ const token = bearerToken(req);
142
+ if (token) {
143
+ if (deviceAuthEnabled) {
144
+ try {
145
+ const claims = await verifyAccessToken(store, config, token);
146
+ return { kind: "bearer", claims };
147
+ } catch {
148
+ // Fall through to API keys. Both use Bearer by design.
149
+ }
150
+ }
151
+ if (apiKeysEnabled) {
152
+ const apiKey = authenticateApiKey(store, token);
153
+ if (apiKey) {
154
+ return { kind: "api-key", ...apiKey };
155
+ }
156
+ }
157
+ return errorResponse(401, "invalid_token", "Bearer token is invalid");
158
+ }
159
+ if (passkeysEnabled && isPasskeyAuthRequired(store, config)) {
160
+ if (!hasPasskeySession(req, store)) {
161
+ return required ? errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
162
+ headers: { "x-webapp-passkey-required": "true" },
163
+ }) : { kind: "anonymous" };
164
+ }
165
+ return { kind: "passkey" };
166
+ }
167
+ return { kind: "anonymous" };
168
+ }
169
+
170
+ function configResponse(req: Request): WebAppConfigResponse {
171
+ return {
172
+ appName: config.appName,
173
+ version,
174
+ passkeyAuth: passkeyStatus(req, store, config, passkeysEnabled),
175
+ logLevel: {
176
+ level: (config.logLevelFromEnv ? config.logLevel : store.getLogLevelPreference() ?? config.logLevel) as LogLevelName,
177
+ fromEnv: config.logLevelFromEnv,
178
+ },
179
+ apiKeys: { enabled: Boolean(apiKeysEnabled) },
180
+ deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
181
+ };
182
+ }
183
+
184
+ async function handleBuiltIn(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
185
+ const url = new URL(req.url);
186
+ const path = url.pathname;
187
+ try {
188
+ if (path === "/api/health" && req.method === "GET") {
189
+ return successResponse({ ok: true, version });
190
+ }
191
+ if (path === "/api/config" && req.method === "GET") {
192
+ return jsonResponse(configResponse(req));
193
+ }
194
+ if (path === wsPath) {
195
+ const auth = await authorize(req, true);
196
+ if (auth instanceof Response) return auth;
197
+ const originFailure = checkSameOrigin(req, config, auth, "always");
198
+ if (originFailure) return originFailure;
199
+ if (!server) return errorResponse(400, "websocket_unavailable", "WebSocket server is unavailable");
200
+ const filters = Object.fromEntries(url.searchParams.entries());
201
+ const upgraded = server.upgrade(req, { data: { filters } });
202
+ return upgraded ? undefined : errorResponse(400, "websocket_upgrade_failed", "WebSocket upgrade failed");
203
+ }
204
+ if (path === "/api/passkey-auth/status" && req.method === "GET") {
205
+ return jsonResponse(passkeyStatus(req, store, config, passkeysEnabled));
206
+ }
207
+ if (passkeysEnabled && path === "/api/passkey-auth/registration/options" && req.method === "POST") {
208
+ const result = await beginRegistration(req, store, config);
209
+ return addHeaders(jsonResponse(result.options), result.headers);
210
+ }
211
+ if (passkeysEnabled && path === "/api/passkey-auth/registration/verify" && req.method === "POST") {
212
+ const headers = await completeRegistration(req, store, config, await parseJson(req));
213
+ return addHeaders(successResponse(), headers);
214
+ }
215
+ if (passkeysEnabled && path === "/api/passkey-auth/authentication/options" && req.method === "POST") {
216
+ const result = await beginAuthentication(req, store, config);
217
+ return addHeaders(jsonResponse(result.options), result.headers);
218
+ }
219
+ if (passkeysEnabled && path === "/api/passkey-auth/authentication/verify" && req.method === "POST") {
220
+ const headers = await completeAuthentication(req, store, config, await parseJson(req));
221
+ return addHeaders(successResponse(), headers);
222
+ }
223
+ if (passkeysEnabled && path === "/api/passkey-auth/logout" && req.method === "POST") {
224
+ return addHeaders(successResponse(), logoutHeaders(req, config));
225
+ }
226
+ if (passkeysEnabled && path === "/api/passkey-auth/passkey" && req.method === "DELETE") {
227
+ const auth = await authorize(req, true);
228
+ if (auth instanceof Response) return auth;
229
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
230
+ if (originFailure) return originFailure;
231
+ return addHeaders(successResponse(), deletePasskey(req, store, config));
232
+ }
233
+ if (apiKeysEnabled && path === "/api/api-keys" && req.method === "GET") {
234
+ const auth = await authorize(req, true);
235
+ if (auth instanceof Response) return auth;
236
+ return jsonResponse(listApiKeys(store));
237
+ }
238
+ if (apiKeysEnabled && path === "/api/api-keys" && req.method === "POST") {
239
+ const auth = await authorize(req, true);
240
+ if (auth instanceof Response) return auth;
241
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
242
+ if (originFailure) return originFailure;
243
+ return jsonResponse(createApiKey(store, await parseJson(req)));
244
+ }
245
+ const apiKeyDelete = /^\/api\/api-keys\/([^/]+)$/.exec(path);
246
+ if (apiKeysEnabled && apiKeyDelete && req.method === "DELETE") {
247
+ const auth = await authorize(req, true);
248
+ if (auth instanceof Response) return auth;
249
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
250
+ if (originFailure) return originFailure;
251
+ deleteApiKey(store, decodeURIComponent(apiKeyDelete[1]!));
252
+ return successResponse();
253
+ }
254
+ if (deviceAuthEnabled && path === "/api/auth/device" && req.method === "POST") {
255
+ const body = await parseJson<{ client_id?: string; clientId?: string; scope?: string }>(req).catch((): { client_id?: string; clientId?: string; scope?: string } => ({}));
256
+ return jsonResponse(createDeviceAuthorization(req, store, config, { clientId: body.client_id ?? body.clientId, scope: body.scope }));
257
+ }
258
+ if (deviceAuthEnabled && path === "/api/auth/device/verification" && req.method === "GET") {
259
+ const auth = await authorize(req, true);
260
+ if (auth instanceof Response) return auth;
261
+ const userCode = url.searchParams.get("user_code")?.trim();
262
+ if (!userCode) return errorResponse(400, "invalid_user_code", "user_code is required");
263
+ return jsonResponse(getDeviceVerificationDetails(store, userCode, passkeysEnabled && isPasskeyAuthRequired(store, config)));
264
+ }
265
+ if (deviceAuthEnabled && path === "/api/auth/device/approve" && req.method === "POST") {
266
+ const auth = await authorize(req, true);
267
+ if (auth instanceof Response) return auth;
268
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
269
+ if (originFailure) return originFailure;
270
+ const body = await parseJson<{ userCode?: string; user_code?: string }>(req);
271
+ return jsonResponse(approveDevice(store, body.userCode ?? body.user_code ?? ""));
272
+ }
273
+ if (deviceAuthEnabled && path === "/api/auth/device/deny" && req.method === "POST") {
274
+ const auth = await authorize(req, true);
275
+ if (auth instanceof Response) return auth;
276
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
277
+ if (originFailure) return originFailure;
278
+ const body = await parseJson<{ userCode?: string; user_code?: string }>(req);
279
+ return jsonResponse(denyDevice(store, body.userCode ?? body.user_code ?? ""));
280
+ }
281
+ if (deviceAuthEnabled && (path === "/api/auth/token" || path === "/api/auth/refresh") && req.method === "POST") {
282
+ const body = await parseJson<{ grant_type?: string; device_code?: string; refresh_token?: string; client_id?: string }>(req);
283
+ try {
284
+ if (body.grant_type === "urn:ietf:params:oauth:grant-type:device_code" || body.device_code) {
285
+ return jsonResponse(await exchangeDeviceCode(store, config, body.device_code ?? "", body.client_id));
286
+ }
287
+ return jsonResponse(await exchangeRefreshToken(store, config, body.refresh_token ?? "", body.client_id));
288
+ } catch (error) {
289
+ return tokenError(error);
290
+ }
291
+ }
292
+ if (deviceAuthEnabled && path === "/api/auth/revoke" && req.method === "POST") {
293
+ const body = await parseJson<{ refreshToken?: string; refresh_token?: string }>(req);
294
+ revokeRefreshToken(store, body.refreshToken ?? body.refresh_token ?? "");
295
+ return successResponse();
296
+ }
297
+ if (deviceAuthEnabled && path === "/api/auth/sessions" && req.method === "GET") {
298
+ const auth = await authorize(req, true);
299
+ if (auth instanceof Response) return auth;
300
+ return jsonResponse(listAuthSessions(store));
301
+ }
302
+ const sessionDelete = /^\/api\/auth\/sessions\/([^/]+)$/.exec(path);
303
+ if (deviceAuthEnabled && sessionDelete && req.method === "DELETE") {
304
+ const auth = await authorize(req, true);
305
+ if (auth instanceof Response) return auth;
306
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
307
+ if (originFailure) return originFailure;
308
+ return revokeAuthSession(store, decodeURIComponent(sessionDelete[1]!)) ? successResponse() : notFound();
309
+ }
310
+ if (deviceAuthEnabled && path === "/.well-known/jwks.json" && req.method === "GET") {
311
+ return jsonResponse(await jwks(store));
312
+ }
313
+ if (deviceAuthEnabled && path === "/.well-known/openid-configuration" && req.method === "GET") {
314
+ return jsonResponse(discovery(req, config));
315
+ }
316
+ if (path === "/api/preferences/theme") {
317
+ const auth = await authorize(req, true);
318
+ if (auth instanceof Response) return auth;
319
+ if (req.method === "GET") {
320
+ return jsonResponse({ theme: store.getThemePreference() ?? "system" });
321
+ }
322
+ if (req.method === "PUT") {
323
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
324
+ if (originFailure) return originFailure;
325
+ const body = await parseJson<{ theme: ThemePreference }>(req);
326
+ store.setThemePreference(body.theme);
327
+ return successResponse({ theme: body.theme });
328
+ }
329
+ }
330
+ if (path === "/api/preferences/log-level") {
331
+ const auth = await authorize(req, true);
332
+ if (auth instanceof Response) return auth;
333
+ if (req.method === "GET") {
334
+ return jsonResponse({ level: store.getLogLevelPreference() ?? config.logLevel, fromEnv: config.logLevelFromEnv });
335
+ }
336
+ if (req.method === "PUT") {
337
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
338
+ if (originFailure) return originFailure;
339
+ if (config.logLevelFromEnv) return errorResponse(409, "log_level_from_env", "Log level is controlled by environment");
340
+ const body = await parseJson<{ level: LogLevelName }>(req);
341
+ store.setLogLevelPreference(body.level);
342
+ setLogLevel(body.level);
343
+ return successResponse({ level: body.level });
344
+ }
345
+ }
346
+ if (path === "/api/server/kill" && req.method === "POST") {
347
+ const auth = await authorize(req, true);
348
+ if (auth instanceof Response) return auth;
349
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
350
+ if (originFailure) return originFailure;
351
+ setTimeout(() => process.exit(0), 100);
352
+ return successResponse({ success: true, message: "Server is shutting down" });
353
+ }
354
+ if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
355
+ return htmlResponse(input.index);
356
+ }
357
+ } catch (error) {
358
+ return authErrorResponse(error);
359
+ }
360
+ return undefined;
361
+ }
362
+
363
+ async function handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
364
+ const url = new URL(req.url);
365
+ if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
366
+ const builtIn = await handleBuiltIn(req, server);
367
+ if (builtIn) {
368
+ return withSecurityHeaders(builtIn);
369
+ }
370
+ const matched = matchRoute(routes, url.pathname);
371
+ if (!matched) {
372
+ return withSecurityHeaders(notFound());
373
+ }
374
+ const handler = matched.route[method(req) ?? "GET"];
375
+ if (!handler) {
376
+ return withSecurityHeaders(methodNotAllowed());
377
+ }
378
+ const required = (matched.route.auth ?? "required") === "required";
379
+ const auth = await authorize(req, required);
380
+ if (auth instanceof Response) {
381
+ return withSecurityHeaders(auth);
382
+ }
383
+ try {
384
+ if ((matched.route.auth ?? "required") !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
385
+ assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
386
+ }
387
+ } catch (error) {
388
+ return withSecurityHeaders(authErrorResponse(error));
389
+ }
390
+ const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
391
+ if (originFailure) {
392
+ return withSecurityHeaders(originFailure);
393
+ }
394
+ return withSecurityHeaders(await handler(req, { params: matched.params, auth, realtime, server }));
395
+ }
396
+ return htmlResponse(input.index);
397
+ }
398
+
399
+ function start(): Server<WebSocketData> {
400
+ const dynamicHandler = (req: Request, server: Server<WebSocketData>) => handleRequest(req, server);
401
+ const server = Bun.serve<WebSocketData>({
402
+ hostname: config.host,
403
+ port: config.port,
404
+ routes: {
405
+ "/api/*": dynamicHandler,
406
+ "/.well-known/*": dynamicHandler,
407
+ "/device": dynamicHandler,
408
+ "/*": canRespondWithIndex(input.index) ? dynamicHandler : input.index as never,
409
+ },
410
+ websocket: {
411
+ open(socket) {
412
+ realtime.add(socket);
413
+ },
414
+ message(socket, message) {
415
+ if (message === "ping") {
416
+ socket.send(JSON.stringify({ type: "pong" }));
417
+ }
418
+ },
419
+ close(socket) {
420
+ realtime.remove(socket);
421
+ },
422
+ },
423
+ development: config.development,
424
+ });
425
+ log.info(`${config.appName} server running`, { url: String(server.url) });
426
+ return server;
427
+ }
428
+
429
+ async function runFromCli(argv = Bun.argv.slice(2)): Promise<void> {
430
+ const command = argv[0] ?? "serve";
431
+ if (command === "serve") {
432
+ start();
433
+ return await new Promise(() => undefined);
434
+ }
435
+ if (command === "version") {
436
+ console.log(version);
437
+ return;
438
+ }
439
+ if (command === "config") {
440
+ console.log(JSON.stringify(safeRuntimeConfig(config), null, 2));
441
+ return;
442
+ }
443
+ throw new Error(`Unknown command: ${command}`);
444
+ }
445
+
446
+ return { config, store, realtime, handleRequest, start, runFromCli };
447
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./runtime-config";
2
+ export * from "./routes";
3
+ export * from "./responses";
4
+ export * from "./create-web-app-server";
5
+ export * from "./auth/store";
6
+ export * from "./auth/sqlite-store";
7
+ export * from "./realtime/bus";
@@ -0,0 +1,44 @@
1
+ import type { LogLevelName } from "../contracts";
2
+
3
+ const ORDER: Record<LogLevelName, number> = {
4
+ trace: 10,
5
+ debug: 20,
6
+ info: 30,
7
+ warn: 40,
8
+ error: 50,
9
+ };
10
+
11
+ let currentLevel: LogLevelName = "info";
12
+
13
+ export function setLogLevel(level: LogLevelName): void {
14
+ currentLevel = level;
15
+ }
16
+
17
+ export function getLogLevel(): LogLevelName {
18
+ return currentLevel;
19
+ }
20
+
21
+ export function createLogger(scope: string) {
22
+ function log(level: LogLevelName, message: string, fields?: Record<string, unknown>): void {
23
+ if (ORDER[level] < ORDER[currentLevel]) {
24
+ return;
25
+ }
26
+ const timestamp = new Date().toISOString();
27
+ const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
28
+ const line = `${timestamp}\t${level.toUpperCase()}\t${scope}\t${message}${suffix}`;
29
+ if (level === "error") {
30
+ console.error(line);
31
+ } else if (level === "warn") {
32
+ console.warn(line);
33
+ } else {
34
+ console.log(line);
35
+ }
36
+ }
37
+ return {
38
+ trace: (message: string, fields?: Record<string, unknown>) => log("trace", message, fields),
39
+ debug: (message: string, fields?: Record<string, unknown>) => log("debug", message, fields),
40
+ info: (message: string, fields?: Record<string, unknown>) => log("info", message, fields),
41
+ warn: (message: string, fields?: Record<string, unknown>) => log("warn", message, fields),
42
+ error: (message: string, fields?: Record<string, unknown>) => log("error", message, fields),
43
+ };
44
+ }
@@ -0,0 +1,104 @@
1
+ import type { ServerWebSocket } from "bun";
2
+
3
+ export interface WebSocketData {
4
+ filters?: Record<string, string>;
5
+ }
6
+
7
+ export type RealtimeAction = "created" | "updated" | "changed" | "deleted";
8
+
9
+ export interface ResourceRealtimeEvent<TPayload = unknown> {
10
+ type: `${string}.${RealtimeAction}`;
11
+ resource: string;
12
+ action: RealtimeAction;
13
+ id?: string;
14
+ scope?: string;
15
+ payload?: TPayload;
16
+ }
17
+
18
+ export type RealtimeTarget = {
19
+ resource?: string;
20
+ id?: string;
21
+ scope?: string;
22
+ } & Record<string, string | undefined>;
23
+
24
+ export interface RealtimePublishOptions {
25
+ target?: RealtimeTarget;
26
+ filter?: (socket: ServerWebSocket<WebSocketData>) => boolean;
27
+ }
28
+
29
+ export type RealtimeMessage<TEvent> =
30
+ | { type: "event"; event: TEvent }
31
+ | { type: "ping" }
32
+ | { type: "pong" };
33
+
34
+ function targetMatches(filters: Record<string, string> | undefined, target: RealtimeTarget | undefined): boolean {
35
+ if (!target) return true;
36
+ if (!filters) return true;
37
+ for (const [key, value] of Object.entries(filters)) {
38
+ if (value !== undefined && target[key] !== value) {
39
+ return false;
40
+ }
41
+ }
42
+ return true;
43
+ }
44
+
45
+ export class RealtimeBus<TEvent = unknown> {
46
+ private sockets = new Set<ServerWebSocket<WebSocketData>>();
47
+
48
+ add(socket: ServerWebSocket<WebSocketData>): void {
49
+ this.sockets.add(socket);
50
+ }
51
+
52
+ remove(socket: ServerWebSocket<WebSocketData>): void {
53
+ this.sockets.delete(socket);
54
+ }
55
+
56
+ publish(event: TEvent, options?: RealtimePublishOptions | ((socket: ServerWebSocket<WebSocketData>) => boolean)): void {
57
+ const publishOptions = typeof options === "function" ? { filter: options } : options;
58
+ const payload = JSON.stringify({ type: "event", event } satisfies RealtimeMessage<TEvent>);
59
+ for (const socket of this.sockets) {
60
+ if (targetMatches(socket.data.filters, publishOptions?.target) && (!publishOptions?.filter || publishOptions.filter(socket))) {
61
+ socket.send(payload);
62
+ }
63
+ }
64
+ }
65
+
66
+ publishChanged<TPayload = unknown>(resource: string, options: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget } = {}): void {
67
+ this.publishResource(resource, "changed", options);
68
+ }
69
+
70
+ publishEntityChanged<TPayload = unknown>(resource: string, id: string, options: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget } = {}): void {
71
+ this.publishResource(resource, "changed", { ...options, id });
72
+ }
73
+
74
+ publishDeleted<TPayload = unknown>(resource: string, id: string, options: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action" | "id"> & { target?: RealtimeTarget } = {}): void {
75
+ this.publishResource(resource, "deleted", { ...options, id });
76
+ }
77
+
78
+ publishSettingsChanged<TPayload = unknown>(options: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget } = {}): void {
79
+ this.publishResource("settings", "changed", options);
80
+ }
81
+
82
+ publishResource<TPayload = unknown>(
83
+ resource: string,
84
+ action: RealtimeAction,
85
+ options: Omit<ResourceRealtimeEvent<TPayload>, "type" | "resource" | "action"> & { target?: RealtimeTarget } = {},
86
+ ): void {
87
+ const { target, ...eventOptions } = options;
88
+ const event = {
89
+ type: `${resource}.${action}`,
90
+ resource,
91
+ action,
92
+ ...eventOptions,
93
+ } satisfies ResourceRealtimeEvent<TPayload>;
94
+ this.publish(event as TEvent, { target: target ?? { resource, ...(event.id ? { id: event.id } : {}), ...(event.scope ? { scope: event.scope } : {}) } });
95
+ }
96
+
97
+ get connectionCount(): number {
98
+ return this.sockets.size;
99
+ }
100
+ }
101
+
102
+ export function createRealtimeBus<TEvent = unknown>(): RealtimeBus<TEvent> {
103
+ return new RealtimeBus<TEvent>();
104
+ }
@@ -0,0 +1,54 @@
1
+ import type { WebAppErrorResponse } from "../contracts";
2
+
3
+ export function jsonResponse<T>(data: T, init: ResponseInit = {}): Response {
4
+ const headers = new Headers(init.headers);
5
+ if (!headers.has("content-type")) {
6
+ headers.set("content-type", "application/json; charset=utf-8");
7
+ }
8
+ return new Response(JSON.stringify(data), { ...init, headers });
9
+ }
10
+
11
+ export function successResponse<T extends object = { success: true }>(data = { success: true } as T, init: ResponseInit = {}): Response {
12
+ return jsonResponse(data, init);
13
+ }
14
+
15
+ export function errorResponse(status: number, error: string, message: string, details?: unknown, init: ResponseInit = {}): Response {
16
+ return jsonResponse<WebAppErrorResponse>(
17
+ { error, message, ...(details === undefined ? {} : { details }) },
18
+ { ...init, status },
19
+ );
20
+ }
21
+
22
+ export function notFound(): Response {
23
+ return errorResponse(404, "not_found", "The requested resource was not found");
24
+ }
25
+
26
+ export function methodNotAllowed(): Response {
27
+ return errorResponse(405, "method_not_allowed", "Method not allowed");
28
+ }
29
+
30
+ export async function parseJson<T>(req: Request): Promise<T> {
31
+ try {
32
+ return await req.json() as T;
33
+ } catch {
34
+ throw new Error("Request body must be valid JSON");
35
+ }
36
+ }
37
+
38
+ export function applySecurityHeaders(headers: Headers): Headers {
39
+ if (!headers.has("referrer-policy")) {
40
+ headers.set("referrer-policy", "strict-origin-when-cross-origin");
41
+ }
42
+ if (!headers.has("x-frame-options")) {
43
+ headers.set("x-frame-options", "DENY");
44
+ }
45
+ if (!headers.has("content-security-policy")) {
46
+ headers.set("content-security-policy", "frame-ancestors 'none'");
47
+ }
48
+ return headers;
49
+ }
50
+
51
+ export function withSecurityHeaders(response: Response): Response {
52
+ applySecurityHeaders(response.headers);
53
+ return response;
54
+ }