@pablozaiden/webapp 0.5.8 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 -1214
- 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 +76 -64
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import type {
|
|
6
|
+
PublicRouteDefinition,
|
|
7
|
+
WebAppDocumentConfig,
|
|
8
|
+
WebAppIconConfig,
|
|
9
|
+
WebAppPwaConfig,
|
|
10
|
+
} from "./server-types";
|
|
11
|
+
import { findPackageRoot, resolveReactDomClient } from "../package-resolution";
|
|
12
|
+
import { MOBILE_MEDIA_QUERY, MOBILE_STATE_ATTRIBUTE } from "../web/mobile";
|
|
13
|
+
import { notFound, withSecurityHeaders } from "./responses";
|
|
14
|
+
import type { RuntimeConfig } from "./runtime-config";
|
|
15
|
+
|
|
16
|
+
type HtmlBundleIndex = { index: string };
|
|
17
|
+
|
|
18
|
+
interface WebDocumentResolution {
|
|
19
|
+
entryFile?: string;
|
|
20
|
+
packageRoot: string;
|
|
21
|
+
reactDomClientPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WebDocument {
|
|
25
|
+
bundle?: HtmlBundleIndex;
|
|
26
|
+
entryPublicPath: string;
|
|
27
|
+
cacheDir: string;
|
|
28
|
+
html: string;
|
|
29
|
+
manifest: string;
|
|
30
|
+
icon: string;
|
|
31
|
+
generatedPublicRoutes: Record<string, PublicRouteDefinition>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WebDocumentProvider {
|
|
35
|
+
readonly generatedRoutePaths: ReadonlySet<string>;
|
|
36
|
+
ensure(): Promise<WebDocument>;
|
|
37
|
+
dispose(document: WebDocument): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type CompiledClientAsset = {
|
|
41
|
+
path: string;
|
|
42
|
+
contentType: string;
|
|
43
|
+
role: "script" | "style" | "asset";
|
|
44
|
+
scriptOrder?: number;
|
|
45
|
+
body: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type CompiledClient = {
|
|
49
|
+
packageRoot: string;
|
|
50
|
+
assets: CompiledClientAsset[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DEFAULT_WEB_ENTRY = "./web/main.tsx";
|
|
54
|
+
const DEFAULT_THEME_COLOR = "#111827";
|
|
55
|
+
const DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
|
56
|
+
const WEBAPP_DOCUMENT_CACHE_PREFIX = "webapp-document-";
|
|
57
|
+
const documentCacheDirs = new Set<string>();
|
|
58
|
+
let documentCacheCleanupRegistered = false;
|
|
59
|
+
|
|
60
|
+
function cleanupDocumentCacheDir(cacheDir: string): void {
|
|
61
|
+
if (!documentCacheDirs.delete(cacheDir)) return;
|
|
62
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cleanupDocumentCacheDirs(): void {
|
|
66
|
+
for (const cacheDir of Array.from(documentCacheDirs)) {
|
|
67
|
+
cleanupDocumentCacheDir(cacheDir);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeCachePathSegment(value: string): string {
|
|
72
|
+
return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createDocumentCacheDir(envPrefix: string): string {
|
|
76
|
+
const root = join(tmpdir(), "webapp", safeCachePathSegment(envPrefix));
|
|
77
|
+
mkdirSync(root, { recursive: true });
|
|
78
|
+
const cacheDir = realpathSync(mkdtempSync(join(root, WEBAPP_DOCUMENT_CACHE_PREFIX)));
|
|
79
|
+
documentCacheDirs.add(cacheDir);
|
|
80
|
+
if (!documentCacheCleanupRegistered) {
|
|
81
|
+
documentCacheCleanupRegistered = true;
|
|
82
|
+
process.once("exit", cleanupDocumentCacheDirs);
|
|
83
|
+
}
|
|
84
|
+
return cacheDir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isHtmlBundleIndex(index: unknown): index is HtmlBundleIndex {
|
|
88
|
+
return typeof index === "object"
|
|
89
|
+
&& index !== null
|
|
90
|
+
&& "index" in index
|
|
91
|
+
&& typeof (index as { index?: unknown }).index === "string"
|
|
92
|
+
&& String(index) === "[object HTMLBundle]";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requestLooksLikeNavigation(req?: Request): boolean {
|
|
96
|
+
if (!req) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
const url = new URL(req.url);
|
|
100
|
+
const lastSegment = url.pathname.split("/").pop() ?? "";
|
|
101
|
+
const hasFileExtension = /\.[A-Za-z0-9]+$/.test(lastSegment);
|
|
102
|
+
if (hasFileExtension) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const accept = req.headers.get("accept");
|
|
106
|
+
return !accept || accept.includes("text/html") || accept.includes("*/*");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function htmlResponse(document: WebDocument, req?: Request): Promise<Response> {
|
|
110
|
+
if (!requestLooksLikeNavigation(req)) {
|
|
111
|
+
return withSecurityHeaders(notFound());
|
|
112
|
+
}
|
|
113
|
+
return withSecurityHeaders(new Response(document.html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasOwnPublicRoute(publicRoutes: Record<string, PublicRouteDefinition>, path: string): boolean {
|
|
117
|
+
return Object.prototype.hasOwnProperty.call(publicRoutes, path);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function escapeHtml(value: string): string {
|
|
121
|
+
return value
|
|
122
|
+
.replace(/&/g, "&")
|
|
123
|
+
.replace(/</g, "<")
|
|
124
|
+
.replace(/>/g, ">")
|
|
125
|
+
.replace(/"/g, """);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function escapeAttribute(value: string): string {
|
|
129
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizePublicPath(path: string): string {
|
|
133
|
+
const trimmed = path.trim();
|
|
134
|
+
if (!trimmed) return "/";
|
|
135
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toWebPath(path: string): string {
|
|
139
|
+
return path.split(sep).join("/");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function localFileUrlPath(url: URL, label: string): string {
|
|
143
|
+
if (url.protocol !== "file:") {
|
|
144
|
+
throw new Error(`${label} must be a local file path or file: URL; received ${url.protocol} URL`);
|
|
145
|
+
}
|
|
146
|
+
return fileURLToPath(url);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveMaybeUrlString(value: string, label: string): string | undefined {
|
|
150
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return localFileUrlPath(new URL(value), label);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveWebEntry(entry: string | URL | undefined): string {
|
|
157
|
+
if (entry instanceof URL) {
|
|
158
|
+
return localFileUrlPath(entry, "web.entry");
|
|
159
|
+
}
|
|
160
|
+
const value = entry ?? DEFAULT_WEB_ENTRY;
|
|
161
|
+
const urlPath = resolveMaybeUrlString(value, "web.entry");
|
|
162
|
+
if (urlPath) {
|
|
163
|
+
return urlPath;
|
|
164
|
+
}
|
|
165
|
+
if (isAbsolute(value)) {
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
const mainDir = dirname(resolve(Bun.main || process.argv[1] || "."));
|
|
169
|
+
return resolve(mainDir, value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resolveWebAsset(src: string | URL, packageRoot: string): string {
|
|
173
|
+
if (src instanceof URL) {
|
|
174
|
+
return localFileUrlPath(src, "web.icons src");
|
|
175
|
+
}
|
|
176
|
+
const urlPath = resolveMaybeUrlString(src, "web.icons src");
|
|
177
|
+
if (urlPath) {
|
|
178
|
+
return urlPath;
|
|
179
|
+
}
|
|
180
|
+
if (isAbsolute(src)) {
|
|
181
|
+
return src;
|
|
182
|
+
}
|
|
183
|
+
return resolve(packageRoot, src);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function copyWebAsset(src: string, dest: string): Promise<void> {
|
|
187
|
+
await Bun.write(dest, Bun.file(src));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function webEntryPublicPath(entryFile: string, packageRoot: string): string {
|
|
191
|
+
const relativeEntry = relative(packageRoot, entryFile);
|
|
192
|
+
if (relativeEntry.startsWith("..") || isAbsolute(relativeEntry)) {
|
|
193
|
+
throw new Error(`web.entry must resolve inside the app package root: ${packageRoot}`);
|
|
194
|
+
}
|
|
195
|
+
return normalizePublicPath(toWebPath(relativeEntry));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function initialsForAppName(appName: string): string {
|
|
199
|
+
const words = appName.match(/[A-Za-z0-9]+/g) ?? [];
|
|
200
|
+
const initials = words.slice(0, 2).map((word) => word[0]?.toUpperCase()).join("");
|
|
201
|
+
return initials || "W";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function generatedIcon(appName: string, themeColor: string, backgroundColor: string): string {
|
|
205
|
+
const initials = escapeHtml(initialsForAppName(appName));
|
|
206
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="${escapeAttribute(appName)}">
|
|
207
|
+
<rect width="512" height="512" rx="112" fill="${escapeAttribute(themeColor)}"/>
|
|
208
|
+
<circle cx="256" cy="256" r="178" fill="${escapeAttribute(backgroundColor)}" opacity="0.14"/>
|
|
209
|
+
<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>
|
|
210
|
+
</svg>
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pathExtension(path: string): string {
|
|
215
|
+
const pathname = path.includes("://") ? new URL(path).pathname : path;
|
|
216
|
+
const match = pathname.match(/\.([A-Za-z0-9]+)$/);
|
|
217
|
+
return match ? `.${match[1]}` : "";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function contentTypeForIcon(path: string, explicit?: string): string {
|
|
221
|
+
if (explicit) return explicit;
|
|
222
|
+
const ext = pathExtension(path).toLowerCase();
|
|
223
|
+
if (ext === ".svg") return "image/svg+xml";
|
|
224
|
+
if (ext === ".png") return "image/png";
|
|
225
|
+
if (ext === ".ico") return "image/x-icon";
|
|
226
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
227
|
+
return "application/octet-stream";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function themeBootScript(themeColor: string | undefined): string {
|
|
231
|
+
const themeColorUpdate = themeColor
|
|
232
|
+
? `
|
|
233
|
+
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
|
234
|
+
if (metaThemeColor instanceof HTMLMetaElement) metaThemeColor.content = resolved === "dark" ? ${JSON.stringify(themeColor)} : ${JSON.stringify(DEFAULT_BACKGROUND_COLOR)};`
|
|
235
|
+
: "";
|
|
236
|
+
return `(() => {
|
|
237
|
+
const key = "webapp.theme";
|
|
238
|
+
const root = document.documentElement;
|
|
239
|
+
const stored = window.localStorage.getItem(key);
|
|
240
|
+
const preference = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
|
|
241
|
+
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
242
|
+
const resolved = preference === "system" ? (systemDark ? "dark" : "light") : preference;
|
|
243
|
+
root.classList.toggle("dark", resolved === "dark");
|
|
244
|
+
root.style.colorScheme = resolved;
|
|
245
|
+
root.dataset.theme = preference;
|
|
246
|
+
root.dataset.resolvedTheme = resolved;
|
|
247
|
+
${themeColorUpdate}
|
|
248
|
+
})();`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function mobileStateBootScript(): string {
|
|
252
|
+
return `(() => {
|
|
253
|
+
const root = document.documentElement;
|
|
254
|
+
const query = window.matchMedia(${JSON.stringify(MOBILE_MEDIA_QUERY)});
|
|
255
|
+
root.toggleAttribute(${JSON.stringify(MOBILE_STATE_ATTRIBUTE)}, query.matches);
|
|
256
|
+
})();`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function pwaEnabled(web: WebAppDocumentConfig): boolean {
|
|
260
|
+
return typeof web.pwa === "object" ? web.pwa.enabled !== false : web.pwa !== false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function pwaConfig(web: WebAppDocumentConfig): WebAppPwaConfig {
|
|
264
|
+
return typeof web.pwa === "object" ? web.pwa : {};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig, backgroundColor: string, icons: WebAppIconConfig[]): string {
|
|
268
|
+
const pwa = pwaConfig(web);
|
|
269
|
+
return JSON.stringify({
|
|
270
|
+
name: config.appName,
|
|
271
|
+
short_name: web.shortName ?? config.appName,
|
|
272
|
+
start_url: pwa.startUrl ?? "./",
|
|
273
|
+
scope: pwa.scope ?? "./",
|
|
274
|
+
display: pwa.display ?? "standalone",
|
|
275
|
+
background_color: backgroundColor,
|
|
276
|
+
...(web.themeColor ? { theme_color: web.themeColor } : {}),
|
|
277
|
+
icons,
|
|
278
|
+
}, null, 2);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function iconConfig(value: string | URL | WebAppIconConfig | undefined): WebAppIconConfig | undefined {
|
|
282
|
+
if (!value) return undefined;
|
|
283
|
+
return typeof value === "object" && !(value instanceof URL) && "src" in value ? value : { src: value };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function compiledClient(): CompiledClient | undefined {
|
|
287
|
+
const value = (globalThis as { [key: symbol]: unknown })[Symbol.for("webapp.compiledClient")];
|
|
288
|
+
if (!value || typeof value !== "object") return undefined;
|
|
289
|
+
const candidate = value as Partial<CompiledClient>;
|
|
290
|
+
return typeof candidate.packageRoot === "string" && Array.isArray(candidate.assets) ? candidate as CompiledClient : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function generatedHtml(
|
|
294
|
+
config: RuntimeConfig,
|
|
295
|
+
web: WebAppDocumentConfig,
|
|
296
|
+
relativeEntry: string | undefined,
|
|
297
|
+
relativePrelude: string | undefined,
|
|
298
|
+
themeColor: string | undefined,
|
|
299
|
+
faviconPath: string,
|
|
300
|
+
appleTouchPath: string,
|
|
301
|
+
compiledAssets?: CompiledClientAsset[],
|
|
302
|
+
): string {
|
|
303
|
+
const title = escapeHtml(web.title ?? config.appName);
|
|
304
|
+
const shortName = escapeAttribute(web.shortName ?? config.appName);
|
|
305
|
+
const htmlFaviconPath = faviconPath.replace(/^\//, "./");
|
|
306
|
+
const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
|
|
307
|
+
const themeMetaTag = themeColor ? ` <meta name="theme-color" content="${escapeAttribute(themeColor)}" />\n` : "";
|
|
308
|
+
const styleTags = compiledAssets?.filter((asset) => asset.role === "style").map((asset) => ` <link rel="stylesheet" href="${escapeAttribute(asset.path)}" />`).join("\n") ?? "";
|
|
309
|
+
const scriptTags = compiledAssets
|
|
310
|
+
? compiledAssets
|
|
311
|
+
.filter((asset) => asset.role === "script")
|
|
312
|
+
.sort((left, right) => (left.scriptOrder ?? 0) - (right.scriptOrder ?? 0))
|
|
313
|
+
.map((asset) => ` <script type="module" src="${escapeAttribute(asset.path)}"></script>`)
|
|
314
|
+
.join("\n")
|
|
315
|
+
: ` <script type="module" src="${escapeAttribute(relativePrelude ?? "")}"></script>
|
|
316
|
+
<script type="module" src="${escapeAttribute(relativeEntry ?? "")}"></script>`;
|
|
317
|
+
const manifestTags = pwaEnabled(web)
|
|
318
|
+
? ` <link rel="icon" href="${escapeAttribute(htmlFaviconPath)}" />
|
|
319
|
+
<link rel="apple-touch-icon" href="${escapeAttribute(htmlAppleTouchPath)}" />
|
|
320
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
321
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
322
|
+
<meta name="apple-mobile-web-app-title" content="${shortName}" />
|
|
323
|
+
<script>(() => {
|
|
324
|
+
const manifest = document.createElement("link");
|
|
325
|
+
manifest.rel = "manifest";
|
|
326
|
+
manifest.href = "/site.webmanifest";
|
|
327
|
+
document.head.appendChild(manifest);
|
|
328
|
+
})();</script>
|
|
329
|
+
`
|
|
330
|
+
: "";
|
|
331
|
+
return `<!doctype html>
|
|
332
|
+
<html lang="${escapeAttribute(web.lang ?? "en")}">
|
|
333
|
+
<head>
|
|
334
|
+
<meta charset="utf-8" />
|
|
335
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
|
336
|
+
${themeMetaTag}\
|
|
337
|
+
${manifestTags} <title>${title}</title>
|
|
338
|
+
<script>${mobileStateBootScript()}</script>
|
|
339
|
+
<script>${themeBootScript(themeColor)}</script>
|
|
340
|
+
${styleTags}
|
|
341
|
+
</head>
|
|
342
|
+
<body>
|
|
343
|
+
<div id="root"></div>
|
|
344
|
+
${scriptTags}
|
|
345
|
+
</body>
|
|
346
|
+
</html>
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function createWebDocument(
|
|
351
|
+
config: RuntimeConfig,
|
|
352
|
+
webInput: WebAppDocumentConfig | undefined,
|
|
353
|
+
resolution: WebDocumentResolution,
|
|
354
|
+
): Promise<WebDocument> {
|
|
355
|
+
const web = webInput ?? {};
|
|
356
|
+
const compiled = compiledClient();
|
|
357
|
+
const entryFile = compiled ? undefined : resolution.entryFile;
|
|
358
|
+
const iconThemeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
|
|
359
|
+
const backgroundColor = web.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
|
|
360
|
+
const packageRoot = resolution.packageRoot;
|
|
361
|
+
const publicEntry = entryFile ? webEntryPublicPath(entryFile, packageRoot) : "";
|
|
362
|
+
const cacheDir = createDocumentCacheDir(config.envPrefix);
|
|
363
|
+
const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
|
|
364
|
+
const icon = generatedIcon(config.appName, iconThemeColor, backgroundColor);
|
|
365
|
+
const favicon = iconConfig(web.icons?.favicon);
|
|
366
|
+
const appleTouch = iconConfig(web.icons?.appleTouch) ?? favicon;
|
|
367
|
+
const manifestIconConfigs = web.icons?.manifest?.length ? web.icons.manifest : undefined;
|
|
368
|
+
const manifestIcons = manifestIconConfigs
|
|
369
|
+
? manifestIconConfigs.map((manifestIcon, index) => {
|
|
370
|
+
const srcPath = resolveWebAsset(manifestIcon.src, packageRoot);
|
|
371
|
+
const ext = pathExtension(srcPath) || ".png";
|
|
372
|
+
return {
|
|
373
|
+
src: `./webapp-icon-${index + 1}${ext}`,
|
|
374
|
+
sizes: manifestIcon.sizes ?? "any",
|
|
375
|
+
type: contentTypeForIcon(srcPath, manifestIcon.type),
|
|
376
|
+
purpose: manifestIcon.purpose ?? "any maskable",
|
|
377
|
+
};
|
|
378
|
+
})
|
|
379
|
+
: [{ src: "./webapp-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }];
|
|
380
|
+
const manifest = pwaEnabled(web) ? generatedManifest(config, web, backgroundColor, manifestIcons) : "";
|
|
381
|
+
writeFileSync(resolve(cacheDir, "webapp-icon.svg"), icon);
|
|
382
|
+
if (manifest) {
|
|
383
|
+
writeFileSync(resolve(cacheDir, "site.webmanifest"), manifest);
|
|
384
|
+
}
|
|
385
|
+
const preludePath = resolve(cacheDir, "webapp-prelude.ts");
|
|
386
|
+
if (!compiled) {
|
|
387
|
+
const reactDomClientPath = resolution.reactDomClientPath;
|
|
388
|
+
if (!reactDomClientPath) {
|
|
389
|
+
throw new Error("Native web document resolution is missing the resolved react-dom/client module.");
|
|
390
|
+
}
|
|
391
|
+
const frameworkWebPath = toWebPath(fileURLToPath(new URL("../web/index.ts", import.meta.url)));
|
|
392
|
+
writeFileSync(preludePath, `import { createRoot } from ${JSON.stringify(toWebPath(reactDomClientPath))};
|
|
393
|
+
import { configureWebAppRenderer } from ${JSON.stringify(frameworkWebPath)};
|
|
394
|
+
|
|
395
|
+
configureWebAppRenderer(createRoot);
|
|
396
|
+
`);
|
|
397
|
+
}
|
|
398
|
+
const relativeEntry = entryFile ? toWebPath(relative(cacheDir, entryFile)) : undefined;
|
|
399
|
+
const relativePrelude = entryFile ? toWebPath(relative(cacheDir, preludePath)) : undefined;
|
|
400
|
+
const faviconPath = favicon ? `/webapp-favicon${pathExtension(resolveWebAsset(favicon.src, packageRoot)) || ".png"}` : "/webapp-icon.svg";
|
|
401
|
+
const appleTouchPath = appleTouch ? `/webapp-apple-touch-icon${pathExtension(resolveWebAsset(appleTouch.src, packageRoot)) || ".png"}` : faviconPath;
|
|
402
|
+
if (favicon) {
|
|
403
|
+
await copyWebAsset(resolveWebAsset(favicon.src, packageRoot), resolve(cacheDir, faviconPath.slice(1)));
|
|
404
|
+
}
|
|
405
|
+
if (appleTouch) {
|
|
406
|
+
await copyWebAsset(resolveWebAsset(appleTouch.src, packageRoot), resolve(cacheDir, appleTouchPath.slice(1)));
|
|
407
|
+
}
|
|
408
|
+
if (manifestIconConfigs) {
|
|
409
|
+
for (const [index, manifestIcon] of manifestIconConfigs.entries()) {
|
|
410
|
+
const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
|
|
411
|
+
const ext = pathExtension(manifestIconFile) || ".png";
|
|
412
|
+
await copyWebAsset(manifestIconFile, resolve(cacheDir, `webapp-icon-${index + 1}${ext}`));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const compiledAssets = compiled?.assets;
|
|
416
|
+
writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, web.themeColor, faviconPath, appleTouchPath, compiledAssets));
|
|
417
|
+
const bundle = compiledAssets ? undefined : (await import(`${pathToFileURL(htmlPath).href}?v=${Date.now()}-${Math.random()}`)).default;
|
|
418
|
+
if (!compiledAssets && !isHtmlBundleIndex(bundle)) {
|
|
419
|
+
throw new Error("Generated web document did not produce a Bun HTMLBundle");
|
|
420
|
+
}
|
|
421
|
+
const html = bundle ? await Bun.file(bundle.index).text() : await Bun.file(htmlPath).text();
|
|
422
|
+
const generatedPublicRoutes: Record<string, PublicRouteDefinition> = {
|
|
423
|
+
"/webapp-icon.svg": {
|
|
424
|
+
headers: { "content-type": "image/svg+xml; charset=utf-8" },
|
|
425
|
+
GET: icon,
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
if (compiledAssets) {
|
|
429
|
+
for (const asset of compiledAssets) {
|
|
430
|
+
const body = Buffer.from(asset.body, "base64");
|
|
431
|
+
generatedPublicRoutes[asset.path] = {
|
|
432
|
+
headers: { "content-type": asset.contentType },
|
|
433
|
+
GET: () => body,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (favicon) {
|
|
438
|
+
const faviconFile = resolveWebAsset(favicon.src, packageRoot);
|
|
439
|
+
generatedPublicRoutes[faviconPath] = {
|
|
440
|
+
headers: { "content-type": contentTypeForIcon(faviconFile, favicon.type) },
|
|
441
|
+
GET: () => Bun.file(faviconFile),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (appleTouch) {
|
|
445
|
+
const appleTouchFile = resolveWebAsset(appleTouch.src, packageRoot);
|
|
446
|
+
generatedPublicRoutes[appleTouchPath] = {
|
|
447
|
+
headers: { "content-type": contentTypeForIcon(appleTouchFile, appleTouch.type) },
|
|
448
|
+
GET: () => Bun.file(appleTouchFile),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if (manifestIconConfigs) {
|
|
452
|
+
manifestIconConfigs.forEach((manifestIcon, index) => {
|
|
453
|
+
const manifestIconFile = resolveWebAsset(manifestIcon.src, packageRoot);
|
|
454
|
+
const ext = pathExtension(manifestIconFile) || ".png";
|
|
455
|
+
generatedPublicRoutes[`/webapp-icon-${index + 1}${ext}`] = {
|
|
456
|
+
headers: { "content-type": contentTypeForIcon(manifestIconFile, manifestIcon.type) },
|
|
457
|
+
GET: () => Bun.file(manifestIconFile),
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
if (pwaEnabled(web)) {
|
|
462
|
+
generatedPublicRoutes["/site.webmanifest"] = {
|
|
463
|
+
headers: { "content-type": "application/manifest+json; charset=utf-8" },
|
|
464
|
+
GET: manifest,
|
|
465
|
+
};
|
|
466
|
+
generatedPublicRoutes["/manifest.webmanifest"] = {
|
|
467
|
+
headers: { "content-type": "application/manifest+json; charset=utf-8" },
|
|
468
|
+
GET: manifest,
|
|
469
|
+
};
|
|
470
|
+
return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest, icon, generatedPublicRoutes };
|
|
471
|
+
}
|
|
472
|
+
return { bundle, entryPublicPath: publicEntry, cacheDir, html, manifest: "", icon, generatedPublicRoutes };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function resolveWebDocumentResolution(web: WebAppDocumentConfig | undefined): WebDocumentResolution {
|
|
476
|
+
const compiled = compiledClient();
|
|
477
|
+
if (compiled) {
|
|
478
|
+
return { packageRoot: compiled.packageRoot };
|
|
479
|
+
}
|
|
480
|
+
const webEntryFile = resolveWebEntry(web?.entry);
|
|
481
|
+
const packageRoot = findPackageRoot(dirname(webEntryFile));
|
|
482
|
+
return {
|
|
483
|
+
entryFile: webEntryFile,
|
|
484
|
+
packageRoot,
|
|
485
|
+
reactDomClientPath: resolveReactDomClient(packageRoot, webEntryFile),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function createWebDocumentProvider(
|
|
490
|
+
config: RuntimeConfig,
|
|
491
|
+
webInput: WebAppDocumentConfig | undefined,
|
|
492
|
+
publicRoutes: Record<string, PublicRouteDefinition>,
|
|
493
|
+
): WebDocumentProvider {
|
|
494
|
+
const resolution = resolveWebDocumentResolution(webInput);
|
|
495
|
+
const configuredFavicon = iconConfig(webInput?.icons?.favicon);
|
|
496
|
+
const configuredAppleTouch = iconConfig(webInput?.icons?.appleTouch) ?? configuredFavicon;
|
|
497
|
+
const configuredManifestIcons = webInput?.icons?.manifest ?? [];
|
|
498
|
+
const compiled = compiledClient();
|
|
499
|
+
const webEntryFile = resolution.entryFile;
|
|
500
|
+
const webPackageRoot = resolution.packageRoot;
|
|
501
|
+
const generatedRoutePaths = new Set([
|
|
502
|
+
...(webEntryFile ? [webEntryPublicPath(webEntryFile, webPackageRoot)] : []),
|
|
503
|
+
...(compiled?.assets.map((asset) => asset.path) ?? []),
|
|
504
|
+
"/webapp-icon.svg",
|
|
505
|
+
...(configuredFavicon ? [`/webapp-favicon${pathExtension(resolveWebAsset(configuredFavicon.src, webPackageRoot)) || ".png"}`] : []),
|
|
506
|
+
...(configuredAppleTouch ? [`/webapp-apple-touch-icon${pathExtension(resolveWebAsset(configuredAppleTouch.src, webPackageRoot)) || ".png"}`] : []),
|
|
507
|
+
...configuredManifestIcons.map((manifestIcon, index) => `/webapp-icon-${index + 1}${pathExtension(resolveWebAsset(manifestIcon.src, webPackageRoot)) || ".png"}`),
|
|
508
|
+
...(pwaEnabled(webInput ?? {}) ? ["/site.webmanifest", "/manifest.webmanifest"] : []),
|
|
509
|
+
]);
|
|
510
|
+
let documentPromise: Promise<WebDocument> | undefined;
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
generatedRoutePaths,
|
|
514
|
+
async ensure(): Promise<WebDocument> {
|
|
515
|
+
documentPromise ??= createWebDocument(config, webInput, resolution).then((document) => {
|
|
516
|
+
for (const path of Object.keys(document.generatedPublicRoutes)) {
|
|
517
|
+
if (hasOwnPublicRoute(publicRoutes, path)) {
|
|
518
|
+
throw new Error(`publicRoutes cannot override framework-owned web route: ${path}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (document.entryPublicPath && hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
|
|
522
|
+
throw new Error(`publicRoutes cannot override framework-owned web route: ${document.entryPublicPath}`);
|
|
523
|
+
}
|
|
524
|
+
return document;
|
|
525
|
+
});
|
|
526
|
+
return await documentPromise;
|
|
527
|
+
},
|
|
528
|
+
dispose(document: WebDocument): void {
|
|
529
|
+
cleanupDocumentCacheDir(document.cacheDir);
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|