@pablozaiden/webapp 0.4.3 → 0.5.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/docs/cli.md +1 -1
- package/docs/getting-started.md +24 -12
- package/docs/server.md +40 -32
- package/package.json +1 -1
- package/src/server/create-web-app-server.ts +469 -46
- package/src/types.d.ts +5 -0
- package/src/web/render.tsx +10 -1
package/docs/cli.md
CHANGED
package/docs/getting-started.md
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
Apps are one Bun application: backend routes, websocket, React UI and static assets are served by the same server. The framework does not support or require a standalone client dist directory.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
import webIndex from "./index.html";
|
|
7
6
|
import { createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
|
|
8
7
|
|
|
9
8
|
const items: Array<{ id: string; userId: string; title: string }> = [];
|
|
@@ -34,7 +33,6 @@ const routes = defineRoutes({
|
|
|
34
33
|
const app = createWebAppServer({
|
|
35
34
|
appName: "My App",
|
|
36
35
|
envPrefix: "MY_APP",
|
|
37
|
-
index: webIndex,
|
|
38
36
|
auth: { passkeys: true, apiKeys: true, deviceAuth: true },
|
|
39
37
|
realtime: { path: "/api/ws" },
|
|
40
38
|
routes,
|
|
@@ -43,19 +41,33 @@ const app = createWebAppServer({
|
|
|
43
41
|
await app.runFromCli();
|
|
44
42
|
```
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
The framework generates the HTML document, React mount point, viewport metadata, PWA manifest, default SVG icons, and the theme prepaint script. By default it uses `./web/main.tsx` relative to the Bun entry file as the frontend entrypoint, so apps only need to create that file. Override document defaults only when the app needs different metadata:
|
|
47
45
|
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
```ts
|
|
47
|
+
createWebAppServer({
|
|
48
|
+
appName: "My App",
|
|
49
|
+
envPrefix: "MY_APP",
|
|
50
|
+
web: {
|
|
51
|
+
entry: "./frontend.tsx",
|
|
52
|
+
title: "My App",
|
|
53
|
+
shortName: "MyApp",
|
|
54
|
+
themeColor: "#111827",
|
|
55
|
+
backgroundColor: "#ffffff",
|
|
56
|
+
pwa: true,
|
|
57
|
+
icons: {
|
|
58
|
+
favicon: { src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png" },
|
|
59
|
+
appleTouch: { src: "./src/web/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
|
60
|
+
manifest: [
|
|
61
|
+
{ src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
|
|
62
|
+
{ src: "./src/web/icons/app-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
routes,
|
|
67
|
+
});
|
|
56
68
|
```
|
|
57
69
|
|
|
58
|
-
|
|
70
|
+
PWA support is on by default, with generated initials icons unless the app provides `web.icons`. Icon paths are relative to the app package root; production apps should provide at least 192x192 and 512x512 PNG manifest icons plus an Apple touch icon. Set `web.pwa: false` only for apps that deliberately should not be installable. The standard theme preference key is `webapp.theme`; apps should not use app-specific theme storage keys.
|
|
59
71
|
|
|
60
72
|
Apps should stay one app and one binary. Use subcommands for different modes:
|
|
61
73
|
|
package/docs/server.md
CHANGED
|
@@ -94,44 +94,52 @@ Built-in endpoints include:
|
|
|
94
94
|
| `/api/server/kill` | Authenticated server shutdown |
|
|
95
95
|
| `/api/ws` | Realtime websocket by default |
|
|
96
96
|
|
|
97
|
-
##
|
|
97
|
+
## Framework-owned web document and PWA metadata
|
|
98
98
|
|
|
99
|
-
`createWebAppServer`
|
|
99
|
+
`createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file.
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
101
|
+
```ts
|
|
102
|
+
createWebAppServer({
|
|
103
|
+
appName: "My App",
|
|
104
|
+
envPrefix: "MY_APP",
|
|
105
|
+
web: {
|
|
106
|
+
entry: "./frontend.tsx",
|
|
107
|
+
shortName: "MyApp",
|
|
108
|
+
themeColor: "#111827",
|
|
109
|
+
backgroundColor: "#ffffff",
|
|
110
|
+
pwa: true,
|
|
111
|
+
icons: {
|
|
112
|
+
favicon: { src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png" },
|
|
113
|
+
appleTouch: { src: "./src/web/icons/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
|
114
|
+
manifest: [
|
|
115
|
+
{ src: "./src/web/icons/app-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
|
|
116
|
+
{ src: "./src/web/icons/app-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
routes,
|
|
121
|
+
});
|
|
112
122
|
```
|
|
113
123
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
```
|
|
124
|
+
PWA metadata is enabled by default. Without `web.icons`, the framework serves `/site.webmanifest`, `/manifest.webmanifest`, and a generated SVG icon at `/webapp-icon.svg` using the app name initials. Apps with product artwork can override favicon, Apple touch icon, and manifest icons through `web.icons` while keeping the document framework-owned. Set `web.pwa: false` only for apps that intentionally should not be installable. The generated document includes the standard `webapp.theme` prepaint script; apps should use that framework preference instead of app-specific theme storage.
|
|
125
|
+
|
|
126
|
+
For a functional PWA:
|
|
127
|
+
|
|
128
|
+
| Field | Default | Use when |
|
|
129
|
+
| --- | --- | --- |
|
|
130
|
+
| `web.entry` | `./web/main.tsx` | The app frontend entrypoint lives somewhere else, such as Clanky's `./frontend.tsx` |
|
|
131
|
+
| `web.shortName` | `appName` | The installed app label should be shorter than the full name |
|
|
132
|
+
| `web.themeColor` | `#111827` | Browser chrome/install metadata should match product branding |
|
|
133
|
+
| `web.backgroundColor` | `#ffffff` | The manifest background should match the app splash/background |
|
|
134
|
+
| `web.icons.favicon` | Generated initials SVG | Browser tabs should use product artwork instead of initials |
|
|
135
|
+
| `web.icons.appleTouch` | `favicon` or generated initials SVG | iOS home-screen/Dock should use product artwork |
|
|
136
|
+
| `web.icons.manifest` | Generated initials SVG | Installed PWA icons should use product artwork at install sizes |
|
|
137
|
+
|
|
138
|
+
Icon `src` values are resolved relative to the app package root, not the server file. Use paths such as `./src/web/icons/app-192.png` for assets under `src`. Manifest icons should include at least a `192x192` and `512x512` PNG for production apps. SVG defaults are fine for lightweight examples and development, but app-store/Dock integrations vary by platform, so production apps should provide PNG manifest and Apple-touch icons.
|
|
131
139
|
|
|
132
|
-
|
|
140
|
+
The framework serves the manifest at `/site.webmanifest` and `/manifest.webmanifest` and injects the manifest link at runtime so Bun does not rewrite manifest-relative icon URLs into broken asset paths. Favicon and Apple-touch links may be rewritten by Bun to `/_bun/asset/...`; that is expected and keeps hot reload/static asset handling native.
|
|
133
141
|
|
|
134
|
-
|
|
142
|
+
Service workers are not generated by the framework. Apps that need browser push, offline caches, app badge, or background sync should keep a deliberate app-owned service worker route such as `/service-worker`; normal installability does not require one.
|
|
135
143
|
|
|
136
144
|
## Public/static routes
|
|
137
145
|
|
package/package.json
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, 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";
|
|
2
6
|
import type { CurrentUser, LogLevelName, ThemePreference, WebAppConfigResponse, WebAppUserRole } from "../contracts";
|
|
3
7
|
import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
|
|
4
8
|
import {
|
|
@@ -46,7 +50,7 @@ import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, suc
|
|
|
46
50
|
export interface WebAppServerConfig<TEvent = unknown> {
|
|
47
51
|
appName: string;
|
|
48
52
|
envPrefix: string;
|
|
49
|
-
|
|
53
|
+
web?: WebAppDocumentConfig;
|
|
50
54
|
version?: string;
|
|
51
55
|
store?: WebAppStore;
|
|
52
56
|
routes?: RouteTable<TEvent>;
|
|
@@ -89,7 +93,7 @@ export interface WebAppServer<TEvent = unknown> {
|
|
|
89
93
|
store: WebAppStore;
|
|
90
94
|
realtime: RealtimeBus<TEvent>;
|
|
91
95
|
handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined>;
|
|
92
|
-
start(): Server<WebSocketData
|
|
96
|
+
start(): Promise<Server<WebSocketData>>;
|
|
93
97
|
runFromCli(argv?: string[]): Promise<void>;
|
|
94
98
|
}
|
|
95
99
|
|
|
@@ -98,6 +102,81 @@ const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "err
|
|
|
98
102
|
|
|
99
103
|
type HtmlBundleIndex = { index: string };
|
|
100
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
|
+
const DEFAULT_WEB_ENTRY = "./web/main.tsx";
|
|
147
|
+
const DEFAULT_THEME_COLOR = "#111827";
|
|
148
|
+
const DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
|
149
|
+
const WEBAPP_DOCUMENT_CACHE_PREFIX = "webapp-document-";
|
|
150
|
+
const documentCacheDirs = new Set<string>();
|
|
151
|
+
let documentCacheCleanupRegistered = false;
|
|
152
|
+
|
|
153
|
+
function cleanupDocumentCacheDir(cacheDir: string): void {
|
|
154
|
+
if (!documentCacheDirs.delete(cacheDir)) return;
|
|
155
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cleanupDocumentCacheDirs(): void {
|
|
159
|
+
for (const cacheDir of Array.from(documentCacheDirs)) {
|
|
160
|
+
cleanupDocumentCacheDir(cacheDir);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeCachePathSegment(value: string): string {
|
|
165
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createDocumentCacheDir(envPrefix: string): string {
|
|
169
|
+
const root = join(tmpdir(), "webapp", safeCachePathSegment(envPrefix));
|
|
170
|
+
mkdirSync(root, { recursive: true });
|
|
171
|
+
const cacheDir = mkdtempSync(join(root, WEBAPP_DOCUMENT_CACHE_PREFIX));
|
|
172
|
+
documentCacheDirs.add(cacheDir);
|
|
173
|
+
if (!documentCacheCleanupRegistered) {
|
|
174
|
+
documentCacheCleanupRegistered = true;
|
|
175
|
+
process.once("exit", cleanupDocumentCacheDirs);
|
|
176
|
+
}
|
|
177
|
+
return cacheDir;
|
|
178
|
+
}
|
|
179
|
+
|
|
101
180
|
function bearerToken(req: Request): string | undefined {
|
|
102
181
|
const header = req.headers.get("authorization")?.trim();
|
|
103
182
|
if (!header) {
|
|
@@ -164,24 +243,11 @@ function requestLooksLikeNavigation(req?: Request): boolean {
|
|
|
164
243
|
}
|
|
165
244
|
|
|
166
245
|
|
|
167
|
-
async function htmlResponse(
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
const html = await Bun.file(index.index).text();
|
|
171
|
-
return withSecurityHeaders(new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
|
172
|
-
}
|
|
173
|
-
return index as unknown as Response;
|
|
246
|
+
async function htmlResponse(document: WebDocument, req?: Request): Promise<Response> {
|
|
247
|
+
if (!requestLooksLikeNavigation(req)) {
|
|
248
|
+
return withSecurityHeaders(notFound());
|
|
174
249
|
}
|
|
175
|
-
|
|
176
|
-
return withSecurityHeaders(index);
|
|
177
|
-
}
|
|
178
|
-
if (typeof index === "string") {
|
|
179
|
-
return withSecurityHeaders(new Response(index, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
|
180
|
-
}
|
|
181
|
-
if (index instanceof Blob) {
|
|
182
|
-
return withSecurityHeaders(new Response(index));
|
|
183
|
-
}
|
|
184
|
-
return index as Response;
|
|
250
|
+
return withSecurityHeaders(new Response(document.html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
|
185
251
|
}
|
|
186
252
|
|
|
187
253
|
function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
|
|
@@ -202,10 +268,6 @@ function secureDynamicResponse(response: Response): Response {
|
|
|
202
268
|
return response instanceof Response ? withSecurityHeaders(response) : response;
|
|
203
269
|
}
|
|
204
270
|
|
|
205
|
-
function canRespondWithIndex(index: unknown): boolean {
|
|
206
|
-
return index instanceof Response || typeof index === "string" || index instanceof Blob || isHtmlBundleIndex(index);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
271
|
function canUseSpaFallback(req: Request): boolean {
|
|
210
272
|
return req.method === "GET" || req.method === "HEAD";
|
|
211
273
|
}
|
|
@@ -214,6 +276,318 @@ function hasOwnPublicRoute(publicRoutes: Record<string, PublicRouteDefinition>,
|
|
|
214
276
|
return Object.prototype.hasOwnProperty.call(publicRoutes, path);
|
|
215
277
|
}
|
|
216
278
|
|
|
279
|
+
function escapeHtml(value: string): string {
|
|
280
|
+
return value
|
|
281
|
+
.replace(/&/g, "&")
|
|
282
|
+
.replace(/</g, "<")
|
|
283
|
+
.replace(/>/g, ">")
|
|
284
|
+
.replace(/"/g, """);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function escapeAttribute(value: string): string {
|
|
288
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function normalizePublicPath(path: string): string {
|
|
292
|
+
const trimmed = path.trim();
|
|
293
|
+
if (!trimmed) return "/";
|
|
294
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function toWebPath(path: string): string {
|
|
298
|
+
return path.split(sep).join("/");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function localFileUrlPath(url: URL, label: string): string {
|
|
302
|
+
if (url.protocol !== "file:") {
|
|
303
|
+
throw new Error(`${label} must be a local file path or file: URL; received ${url.protocol} URL`);
|
|
304
|
+
}
|
|
305
|
+
return fileURLToPath(url);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function resolveMaybeUrlString(value: string, label: string): string | undefined {
|
|
309
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
return localFileUrlPath(new URL(value), label);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function resolveWebEntry(entry: string | URL | undefined): string {
|
|
316
|
+
if (entry instanceof URL) {
|
|
317
|
+
return localFileUrlPath(entry, "web.entry");
|
|
318
|
+
}
|
|
319
|
+
const value = entry ?? DEFAULT_WEB_ENTRY;
|
|
320
|
+
const urlPath = resolveMaybeUrlString(value, "web.entry");
|
|
321
|
+
if (urlPath) {
|
|
322
|
+
return urlPath;
|
|
323
|
+
}
|
|
324
|
+
if (isAbsolute(value)) {
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
const mainDir = dirname(resolve(Bun.main || process.argv[1] || "."));
|
|
328
|
+
return resolve(mainDir, value);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resolveWebAsset(src: string | URL, packageRoot: string): string {
|
|
332
|
+
if (src instanceof URL) {
|
|
333
|
+
return localFileUrlPath(src, "web.icons src");
|
|
334
|
+
}
|
|
335
|
+
const urlPath = resolveMaybeUrlString(src, "web.icons src");
|
|
336
|
+
if (urlPath) {
|
|
337
|
+
return urlPath;
|
|
338
|
+
}
|
|
339
|
+
if (isAbsolute(src)) {
|
|
340
|
+
return src;
|
|
341
|
+
}
|
|
342
|
+
return resolve(packageRoot, src);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function findPackageRoot(start: string): string {
|
|
346
|
+
let current = start;
|
|
347
|
+
while (true) {
|
|
348
|
+
if (existsSync(resolve(current, "package.json"))) {
|
|
349
|
+
return current;
|
|
350
|
+
}
|
|
351
|
+
const parent = dirname(current);
|
|
352
|
+
if (parent === current) {
|
|
353
|
+
return start;
|
|
354
|
+
}
|
|
355
|
+
current = parent;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function copyWebAsset(src: string, dest: string): Promise<void> {
|
|
360
|
+
await Bun.write(dest, Bun.file(src));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function webEntryPublicPath(entryFile: string, packageRoot: string): string {
|
|
364
|
+
const relativeEntry = relative(packageRoot, entryFile);
|
|
365
|
+
if (relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) {
|
|
366
|
+
throw new Error(`web.entry must resolve inside the app package root: ${packageRoot}`);
|
|
367
|
+
}
|
|
368
|
+
return normalizePublicPath(toWebPath(relativeEntry));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function initialsForAppName(appName: string): string {
|
|
372
|
+
const words = appName.match(/[A-Za-z0-9]+/g) ?? [];
|
|
373
|
+
const initials = words.slice(0, 2).map((word) => word[0]?.toUpperCase()).join("");
|
|
374
|
+
return initials || "W";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function generatedIcon(appName: string, themeColor: string, backgroundColor: string): string {
|
|
378
|
+
const initials = escapeHtml(initialsForAppName(appName));
|
|
379
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="${escapeAttribute(appName)}">
|
|
380
|
+
<rect width="512" height="512" rx="112" fill="${escapeAttribute(themeColor)}"/>
|
|
381
|
+
<circle cx="256" cy="256" r="178" fill="${escapeAttribute(backgroundColor)}" opacity="0.14"/>
|
|
382
|
+
<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>
|
|
383
|
+
</svg>
|
|
384
|
+
`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function pathExtension(path: string): string {
|
|
388
|
+
const pathname = path.includes("://") ? new URL(path).pathname : path;
|
|
389
|
+
const match = pathname.match(/\.([A-Za-z0-9]+)$/);
|
|
390
|
+
return match ? `.${match[1]}` : "";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function contentTypeForIcon(path: string, explicit?: string): string {
|
|
394
|
+
if (explicit) return explicit;
|
|
395
|
+
const ext = pathExtension(path).toLowerCase();
|
|
396
|
+
if (ext === ".svg") return "image/svg+xml";
|
|
397
|
+
if (ext === ".png") return "image/png";
|
|
398
|
+
if (ext === ".ico") return "image/x-icon";
|
|
399
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
400
|
+
return "application/octet-stream";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function themeBootScript(themeColor: string): string {
|
|
404
|
+
const darkThemeColor = JSON.stringify(themeColor);
|
|
405
|
+
const lightThemeColor = JSON.stringify(DEFAULT_BACKGROUND_COLOR);
|
|
406
|
+
return `(() => {
|
|
407
|
+
const key = "webapp.theme";
|
|
408
|
+
const root = document.documentElement;
|
|
409
|
+
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
|
410
|
+
const stored = window.localStorage.getItem(key);
|
|
411
|
+
const preference = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
|
|
412
|
+
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
413
|
+
const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference;
|
|
414
|
+
root.classList.toggle("dark", resolved === "dark");
|
|
415
|
+
root.style.colorScheme = resolved;
|
|
416
|
+
root.dataset.theme = preference;
|
|
417
|
+
root.dataset.resolvedTheme = resolved;
|
|
418
|
+
if (metaThemeColor instanceof HTMLMetaElement) metaThemeColor.content = resolved === "dark" ? ${darkThemeColor} : ${lightThemeColor};
|
|
419
|
+
})();`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function pwaEnabled(web: WebAppDocumentConfig): boolean {
|
|
423
|
+
return typeof web.pwa === "object" ? web.pwa.enabled !== false : web.pwa !== false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function pwaConfig(web: WebAppDocumentConfig): WebAppPwaConfig {
|
|
427
|
+
return typeof web.pwa === "object" ? web.pwa : {};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig, themeColor: string, backgroundColor: string, icons: WebAppIconConfig[]): string {
|
|
431
|
+
const pwa = pwaConfig(web);
|
|
432
|
+
return JSON.stringify({
|
|
433
|
+
name: config.appName,
|
|
434
|
+
short_name: web.shortName ?? config.appName,
|
|
435
|
+
start_url: pwa.startUrl ?? "./",
|
|
436
|
+
scope: pwa.scope ?? "./",
|
|
437
|
+
display: pwa.display ?? "standalone",
|
|
438
|
+
background_color: backgroundColor,
|
|
439
|
+
theme_color: themeColor,
|
|
440
|
+
icons,
|
|
441
|
+
}, null, 2);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function iconConfig(value: string | URL | WebAppIconConfig | undefined): WebAppIconConfig | undefined {
|
|
445
|
+
if (!value) return undefined;
|
|
446
|
+
return typeof value === "object" && !(value instanceof URL) && "src" in value ? value : { src: value };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function generatedHtml(config: RuntimeConfig, web: WebAppDocumentConfig, relativeEntry: string, relativePrelude: string, themeColor: string, faviconPath: string, appleTouchPath: string): string {
|
|
450
|
+
const title = escapeHtml(web.title ?? config.appName);
|
|
451
|
+
const shortName = escapeAttribute(web.shortName ?? config.appName);
|
|
452
|
+
const htmlFaviconPath = faviconPath.replace(/^\//, "./");
|
|
453
|
+
const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
|
|
454
|
+
const manifestTags = pwaEnabled(web)
|
|
455
|
+
? ` <link rel="icon" href="${escapeAttribute(htmlFaviconPath)}" />
|
|
456
|
+
<link rel="apple-touch-icon" href="${escapeAttribute(htmlAppleTouchPath)}" />
|
|
457
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
458
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
459
|
+
<meta name="apple-mobile-web-app-title" content="${shortName}" />
|
|
460
|
+
<script>(() => {
|
|
461
|
+
const manifest = document.createElement("link");
|
|
462
|
+
manifest.rel = "manifest";
|
|
463
|
+
manifest.href = "/site.webmanifest";
|
|
464
|
+
document.head.appendChild(manifest);
|
|
465
|
+
})();</script>
|
|
466
|
+
`
|
|
467
|
+
: "";
|
|
468
|
+
return `<!doctype html>
|
|
469
|
+
<html lang="${escapeAttribute(web.lang ?? "en")}">
|
|
470
|
+
<head>
|
|
471
|
+
<meta charset="utf-8" />
|
|
472
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
473
|
+
<meta name="theme-color" content="${escapeAttribute(themeColor)}" />
|
|
474
|
+
${manifestTags} <title>${title}</title>
|
|
475
|
+
<script>${themeBootScript(themeColor)}</script>
|
|
476
|
+
</head>
|
|
477
|
+
<body>
|
|
478
|
+
<div id="root"></div>
|
|
479
|
+
<script type="module" src="${escapeAttribute(relativePrelude)}"></script>
|
|
480
|
+
<script type="module" src="${escapeAttribute(relativeEntry)}"></script>
|
|
481
|
+
</body>
|
|
482
|
+
</html>
|
|
483
|
+
`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocumentConfig | undefined): Promise<WebDocument> {
|
|
487
|
+
const web = webInput ?? {};
|
|
488
|
+
const entryFile = resolveWebEntry(web.entry);
|
|
489
|
+
const themeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
|
|
490
|
+
const backgroundColor = web.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
|
|
491
|
+
const packageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || entryFile)));
|
|
492
|
+
const publicEntry = webEntryPublicPath(entryFile, packageRoot);
|
|
493
|
+
const cacheDir = createDocumentCacheDir(config.envPrefix);
|
|
494
|
+
const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
|
|
495
|
+
const icon = generatedIcon(config.appName, themeColor, backgroundColor);
|
|
496
|
+
const favicon = iconConfig(web.icons?.favicon);
|
|
497
|
+
const appleTouch = iconConfig(web.icons?.appleTouch) ?? favicon;
|
|
498
|
+
const manifestIconConfigs = web.icons?.manifest?.length ? web.icons.manifest : undefined;
|
|
499
|
+
const manifestIcons = manifestIconConfigs
|
|
500
|
+
? manifestIconConfigs.map((manifestIcon, index) => {
|
|
501
|
+
const srcPath = resolveWebAsset(manifestIcon.src, packageRoot);
|
|
502
|
+
const ext = pathExtension(srcPath) || ".png";
|
|
503
|
+
return {
|
|
504
|
+
src: `./webapp-icon-${index + 1}${ext}`,
|
|
505
|
+
sizes: manifestIcon.sizes ?? "any",
|
|
506
|
+
type: contentTypeForIcon(srcPath, manifestIcon.type),
|
|
507
|
+
purpose: manifestIcon.purpose ?? "any maskable",
|
|
508
|
+
};
|
|
509
|
+
})
|
|
510
|
+
: [{ src: "./webapp-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }];
|
|
511
|
+
const manifest = pwaEnabled(web) ? generatedManifest(config, web, themeColor, backgroundColor, manifestIcons) : "";
|
|
512
|
+
writeFileSync(resolve(cacheDir, "webapp-icon.svg"), icon);
|
|
513
|
+
if (manifest) {
|
|
514
|
+
writeFileSync(resolve(cacheDir, "site.webmanifest"), manifest);
|
|
515
|
+
}
|
|
516
|
+
const preludePath = resolve(cacheDir, "webapp-prelude.ts");
|
|
517
|
+
const reactDomClientPath = toWebPath(resolve(packageRoot, "node_modules/react-dom/client.js"));
|
|
518
|
+
const frameworkWebPath = toWebPath(fileURLToPath(new URL("../web/index.ts", import.meta.url)));
|
|
519
|
+
writeFileSync(preludePath, `import { createRoot } from ${JSON.stringify(reactDomClientPath)};
|
|
520
|
+
import { configureWebAppRenderer } from ${JSON.stringify(frameworkWebPath)};
|
|
521
|
+
|
|
522
|
+
configureWebAppRenderer(createRoot);
|
|
523
|
+
`);
|
|
524
|
+
const relativeEntry = toWebPath(relative(cacheDir, entryFile));
|
|
525
|
+
const relativePrelude = toWebPath(relative(cacheDir, preludePath));
|
|
526
|
+
const faviconPath = favicon ? `/webapp-favicon${pathExtension(resolveWebAsset(favicon.src, packageRoot)) || ".png"}` : "/webapp-icon.svg";
|
|
527
|
+
const appleTouchPath = appleTouch ? `/webapp-apple-touch-icon${pathExtension(resolveWebAsset(appleTouch.src, packageRoot)) || ".png"}` : faviconPath;
|
|
528
|
+
if (favicon) {
|
|
529
|
+
await copyWebAsset(resolveWebAsset(favicon.src, packageRoot), resolve(cacheDir, faviconPath.slice(1)));
|
|
530
|
+
}
|
|
531
|
+
if (appleTouch) {
|
|
532
|
+
await copyWebAsset(resolveWebAsset(appleTouch.src, packageRoot), resolve(cacheDir, appleTouchPath.slice(1)));
|
|
533
|
+
}
|
|
534
|
+
if (manifestIconConfigs) {
|
|
535
|
+
for (const [index, manifestIcon] of manifestIconConfigs.entries()) {
|
|
536
|
+
const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
|
|
537
|
+
const ext = pathExtension(manifestIconFile) || ".png";
|
|
538
|
+
await copyWebAsset(manifestIconFile, resolve(cacheDir, `webapp-icon-${index + 1}${ext}`));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, themeColor, faviconPath, appleTouchPath));
|
|
542
|
+
const bundle = (await import(`${pathToFileURL(htmlPath).href}?v=${Date.now()}-${Math.random()}`)).default;
|
|
543
|
+
if (!isHtmlBundleIndex(bundle)) {
|
|
544
|
+
throw new Error("Generated web document did not produce a Bun HTMLBundle");
|
|
545
|
+
}
|
|
546
|
+
const html = await Bun.file(bundle.index).text();
|
|
547
|
+
const generatedPublicRoutes: Record<string, PublicRouteDefinition> = {
|
|
548
|
+
"/webapp-icon.svg": {
|
|
549
|
+
headers: { "content-type": "image/svg+xml; charset=utf-8" },
|
|
550
|
+
GET: icon,
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
if (favicon) {
|
|
554
|
+
const faviconFile = resolveWebAsset(favicon.src, packageRoot);
|
|
555
|
+
generatedPublicRoutes[faviconPath] = {
|
|
556
|
+
headers: { "content-type": contentTypeForIcon(faviconFile, favicon.type) },
|
|
557
|
+
GET: () => Bun.file(faviconFile),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (appleTouch) {
|
|
561
|
+
const appleTouchFile = resolveWebAsset(appleTouch.src, packageRoot);
|
|
562
|
+
generatedPublicRoutes[appleTouchPath] = {
|
|
563
|
+
headers: { "content-type": contentTypeForIcon(appleTouchFile, appleTouch.type) },
|
|
564
|
+
GET: () => Bun.file(appleTouchFile),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (manifestIconConfigs) {
|
|
568
|
+
manifestIconConfigs.forEach((manifestIcon, index) => {
|
|
569
|
+
const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
|
|
570
|
+
const ext = pathExtension(manifestIconFile) || ".png";
|
|
571
|
+
generatedPublicRoutes[`/webapp-icon-${index + 1}${ext}`] = {
|
|
572
|
+
headers: { "content-type": contentTypeForIcon(manifestIconFile, manifestIcon.type) },
|
|
573
|
+
GET: () => Bun.file(manifestIconFile),
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (pwaEnabled(web)) {
|
|
578
|
+
generatedPublicRoutes["/site.webmanifest"] = {
|
|
579
|
+
headers: { "content-type": "application/manifest+json; charset=utf-8" },
|
|
580
|
+
GET: manifest,
|
|
581
|
+
};
|
|
582
|
+
generatedPublicRoutes["/manifest.webmanifest"] = {
|
|
583
|
+
headers: { "content-type": "application/manifest+json; charset=utf-8" },
|
|
584
|
+
GET: manifest,
|
|
585
|
+
};
|
|
586
|
+
return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest, icon, generatedPublicRoutes };
|
|
587
|
+
}
|
|
588
|
+
return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest: "", icon, generatedPublicRoutes };
|
|
589
|
+
}
|
|
590
|
+
|
|
217
591
|
function scopesFromBearer(claims: { scope: string }): string[] {
|
|
218
592
|
return claims.scope.split(/\s+/).filter(Boolean);
|
|
219
593
|
}
|
|
@@ -341,6 +715,35 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
341
715
|
const passkeysEnabled = input.auth?.passkeys !== false;
|
|
342
716
|
const apiKeysEnabled = input.auth?.apiKeys ?? false;
|
|
343
717
|
const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
|
|
718
|
+
const configuredFavicon = iconConfig(input.web?.icons?.favicon);
|
|
719
|
+
const configuredAppleTouch = iconConfig(input.web?.icons?.appleTouch) ?? configuredFavicon;
|
|
720
|
+
const configuredManifestIcons = input.web?.icons?.manifest ?? [];
|
|
721
|
+
const webEntryFile = resolveWebEntry(input.web?.entry);
|
|
722
|
+
const webPackageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || webEntryFile)));
|
|
723
|
+
const generatedRoutePaths = new Set([
|
|
724
|
+
webEntryPublicPath(webEntryFile, webPackageRoot),
|
|
725
|
+
"/webapp-icon.svg",
|
|
726
|
+
...(configuredFavicon ? [`/webapp-favicon${pathExtension(resolveWebAsset(configuredFavicon.src, webPackageRoot)) || ".png"}`] : []),
|
|
727
|
+
...(configuredAppleTouch ? [`/webapp-apple-touch-icon${pathExtension(resolveWebAsset(configuredAppleTouch.src, webPackageRoot)) || ".png"}`] : []),
|
|
728
|
+
...configuredManifestIcons.map((manifestIcon, index) => `/webapp-icon-${index + 1}${pathExtension(resolveWebAsset(manifestIcon.src, webPackageRoot)) || ".png"}`),
|
|
729
|
+
...(pwaEnabled(input.web ?? {}) ? ["/site.webmanifest", "/manifest.webmanifest"] : []),
|
|
730
|
+
]);
|
|
731
|
+
let webDocumentPromise: Promise<WebDocument> | undefined;
|
|
732
|
+
|
|
733
|
+
async function ensureWebDocument(): Promise<WebDocument> {
|
|
734
|
+
webDocumentPromise ??= createWebDocument(config, input.web).then((document) => {
|
|
735
|
+
for (const path of Object.keys(document.generatedPublicRoutes)) {
|
|
736
|
+
if (hasOwnPublicRoute(publicRoutes, path)) {
|
|
737
|
+
throw new Error(`publicRoutes cannot override framework-owned web route: ${path}`);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
|
|
741
|
+
throw new Error(`publicRoutes cannot override framework-owned web route: ${document.entryPublicPath}`);
|
|
742
|
+
}
|
|
743
|
+
return document;
|
|
744
|
+
});
|
|
745
|
+
return await webDocumentPromise;
|
|
746
|
+
}
|
|
344
747
|
|
|
345
748
|
function disabledAuthOwner(): CurrentUser {
|
|
346
749
|
const existing = store.getOwnerUser();
|
|
@@ -744,7 +1147,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
744
1147
|
return successResponse({ success: true, message: "Server is shutting down" });
|
|
745
1148
|
}
|
|
746
1149
|
if (deviceAuthEnabled && path === "/device" && req.method === "GET") {
|
|
747
|
-
return htmlResponse(
|
|
1150
|
+
return htmlResponse(await ensureWebDocument(), req);
|
|
748
1151
|
}
|
|
749
1152
|
} catch (error) {
|
|
750
1153
|
return authErrorResponse(error);
|
|
@@ -754,6 +1157,13 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
754
1157
|
|
|
755
1158
|
async function handlePublicRoute(req: Request): Promise<Response | undefined> {
|
|
756
1159
|
const url = new URL(req.url);
|
|
1160
|
+
if (generatedRoutePaths.has(url.pathname)) {
|
|
1161
|
+
const webDocument = await ensureWebDocument();
|
|
1162
|
+
const generatedRoute = webDocument.generatedPublicRoutes[url.pathname];
|
|
1163
|
+
if (generatedRoute) {
|
|
1164
|
+
return handlePublicRouteValue(req, generatedRoute);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
757
1167
|
if (!hasOwnPublicRoute(publicRoutes, url.pathname)) {
|
|
758
1168
|
return undefined;
|
|
759
1169
|
}
|
|
@@ -761,6 +1171,10 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
761
1171
|
if (!route) {
|
|
762
1172
|
return undefined;
|
|
763
1173
|
}
|
|
1174
|
+
return handlePublicRouteValue(req, route);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
async function handlePublicRouteValue(req: Request, route: PublicRouteDefinition): Promise<Response | undefined> {
|
|
764
1178
|
const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
|
|
765
1179
|
if (!methodName) {
|
|
766
1180
|
return withSecurityHeaders(methodNotAllowed());
|
|
@@ -864,7 +1278,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
864
1278
|
if (!canUseSpaFallback(req)) {
|
|
865
1279
|
return withSecurityHeaders(notFound());
|
|
866
1280
|
}
|
|
867
|
-
return htmlResponse(
|
|
1281
|
+
return htmlResponse(await ensureWebDocument(), req);
|
|
868
1282
|
}
|
|
869
1283
|
|
|
870
1284
|
function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
|
|
@@ -872,34 +1286,38 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
872
1286
|
return handlerName ? appWebsockets[handlerName] : undefined;
|
|
873
1287
|
}
|
|
874
1288
|
|
|
875
|
-
function start(): Server<WebAppWebSocketData
|
|
1289
|
+
async function start(): Promise<Server<WebAppWebSocketData>> {
|
|
1290
|
+
const webDocument = await ensureWebDocument();
|
|
876
1291
|
const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
|
|
877
|
-
const publicRouteHandlers = Object.fromEntries(
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
:
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
OPTIONS: dynamicHandler,
|
|
891
|
-
}
|
|
892
|
-
: input.index as never;
|
|
1292
|
+
const publicRouteHandlers = Object.fromEntries([
|
|
1293
|
+
...Object.keys(webDocument.generatedPublicRoutes),
|
|
1294
|
+
...Object.keys(publicRoutes),
|
|
1295
|
+
].map((path) => [path, dynamicHandler]));
|
|
1296
|
+
const spaFallbackRoute = {
|
|
1297
|
+
GET: dynamicHandler,
|
|
1298
|
+
HEAD: dynamicHandler,
|
|
1299
|
+
POST: dynamicHandler,
|
|
1300
|
+
PUT: dynamicHandler,
|
|
1301
|
+
PATCH: dynamicHandler,
|
|
1302
|
+
DELETE: dynamicHandler,
|
|
1303
|
+
OPTIONS: dynamicHandler,
|
|
1304
|
+
};
|
|
893
1305
|
const server = Bun.serve<WebAppWebSocketData>({
|
|
894
1306
|
hostname: config.host,
|
|
895
1307
|
port: config.port,
|
|
896
1308
|
routes: {
|
|
897
1309
|
...publicRouteHandlers,
|
|
1310
|
+
[webDocument.entryPublicPath]: webDocument.bundle as never,
|
|
898
1311
|
"/api/*": dynamicHandler,
|
|
899
1312
|
"/.well-known/*": dynamicHandler,
|
|
900
|
-
"/device":
|
|
901
|
-
"/setup":
|
|
902
|
-
"/*":
|
|
1313
|
+
"/device": dynamicHandler,
|
|
1314
|
+
"/setup": dynamicHandler,
|
|
1315
|
+
"/*": {
|
|
1316
|
+
...spaFallbackRoute,
|
|
1317
|
+
// Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
|
|
1318
|
+
GET: webDocument.bundle as never,
|
|
1319
|
+
HEAD: webDocument.bundle as never,
|
|
1320
|
+
},
|
|
903
1321
|
},
|
|
904
1322
|
websocket: {
|
|
905
1323
|
open(socket) {
|
|
@@ -934,6 +1352,11 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
934
1352
|
},
|
|
935
1353
|
development: config.development,
|
|
936
1354
|
});
|
|
1355
|
+
const stop = server.stop.bind(server);
|
|
1356
|
+
server.stop = ((closeActiveConnections?: boolean) => {
|
|
1357
|
+
stop(closeActiveConnections);
|
|
1358
|
+
cleanupDocumentCacheDir(webDocument.cacheDir);
|
|
1359
|
+
}) as typeof server.stop;
|
|
937
1360
|
log.info(`${config.appName} server running`, { url: String(server.url) });
|
|
938
1361
|
return server;
|
|
939
1362
|
}
|
|
@@ -941,7 +1364,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
941
1364
|
async function runFromCli(argv = Bun.argv.slice(2)): Promise<void> {
|
|
942
1365
|
const command = argv[0] ?? "serve";
|
|
943
1366
|
if (command === "serve") {
|
|
944
|
-
start();
|
|
1367
|
+
await start();
|
|
945
1368
|
return await new Promise(() => undefined);
|
|
946
1369
|
}
|
|
947
1370
|
if (command === "version") {
|
package/src/types.d.ts
CHANGED
package/src/web/render.tsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import type { Root } from "react-dom/client";
|
|
3
3
|
|
|
4
4
|
declare global {
|
|
5
5
|
var __pablozaidenWebAppRoots: WeakMap<Element, Root> | undefined;
|
|
6
|
+
var __pablozaidenCreateWebAppRoot: ((target: Element) => Root) | undefined;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
function roots(): WeakMap<Element, Root> {
|
|
@@ -10,6 +11,10 @@ function roots(): WeakMap<Element, Root> {
|
|
|
10
11
|
return globalThis.__pablozaidenWebAppRoots;
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
export function configureWebAppRenderer(createRoot: (target: Element) => Root): void {
|
|
15
|
+
globalThis.__pablozaidenCreateWebAppRoot = createRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export function renderWebApp(element: ReactNode, container: Element | string = "root"): Root {
|
|
14
19
|
const target = typeof container === "string" ? document.getElementById(container) : container;
|
|
15
20
|
if (!target) {
|
|
@@ -18,6 +23,10 @@ export function renderWebApp(element: ReactNode, container: Element | string = "
|
|
|
18
23
|
const registry = roots();
|
|
19
24
|
let root = registry.get(target);
|
|
20
25
|
if (!root) {
|
|
26
|
+
const createRoot = globalThis.__pablozaidenCreateWebAppRoot;
|
|
27
|
+
if (!createRoot) {
|
|
28
|
+
throw new Error("Web app renderer is not configured. Use the framework-generated document or call configureWebAppRenderer(createRoot).");
|
|
29
|
+
}
|
|
21
30
|
root = createRoot(target);
|
|
22
31
|
registry.set(target, root);
|
|
23
32
|
}
|