@pablozaiden/webapp 0.5.0 → 0.5.2
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/getting-started.md +26 -0
- package/docs/server.md +1 -1
- package/package.json +1 -1
- package/src/build/build-binary.ts +91 -16
- package/src/server/create-web-app-server.ts +73 -23
- package/src/web/api-client.ts +103 -6
package/docs/getting-started.md
CHANGED
|
@@ -107,6 +107,32 @@ renderWebApp(
|
|
|
107
107
|
`renderWebApp` renders into `#root` by default and reuses the existing React root across hot reloads. Pass a custom element id or `Element` only when the app uses a different mount point.
|
|
108
108
|
`WebAppRoot` owns the shell and `.wapp-main-content`; each route component should return a `Page` wrapper so standard content margins, mobile padding and scroll behavior stay consistent.
|
|
109
109
|
|
|
110
|
+
Use the framework URL helpers for browser API calls, websocket URLs and app-local links instead of deriving paths from `window.location` in each app. They honor `<base>`, explicit `publicBasePath` config, and reverse-proxy subpaths for direct path deep links such as `/workspaces`:
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
import {
|
|
114
|
+
appAbsoluteUrl,
|
|
115
|
+
appFetch,
|
|
116
|
+
appPath,
|
|
117
|
+
appRequest,
|
|
118
|
+
appWebSocketUrl,
|
|
119
|
+
configureWebAppClient,
|
|
120
|
+
setWebAppPublicBasePath,
|
|
121
|
+
} from "@pablozaiden/webapp/web";
|
|
122
|
+
|
|
123
|
+
configureWebAppClient();
|
|
124
|
+
|
|
125
|
+
const config = await appFetch("/api/config").then((res) => res.json());
|
|
126
|
+
setWebAppPublicBasePath(config.publicBasePath);
|
|
127
|
+
|
|
128
|
+
const downloadUrl = appPath("/api/items/export");
|
|
129
|
+
const shareUrl = appAbsoluteUrl("/#/items");
|
|
130
|
+
const socket = new WebSocket(appWebSocketUrl("/api/ws"));
|
|
131
|
+
const rawResponse = await appRequest("/api/items");
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`appFetch` is the framework JSON API helper and throws `WebAppApiError` for non-OK responses. Use `appRequest` when the app needs a raw `Response`, such as downloads, custom error handling, or compatibility wrappers.
|
|
135
|
+
|
|
110
136
|
Recommended dev script:
|
|
111
137
|
|
|
112
138
|
```json
|
package/docs/server.md
CHANGED
|
@@ -127,7 +127,7 @@ For a functional PWA:
|
|
|
127
127
|
|
|
128
128
|
| Field | Default | Use when |
|
|
129
129
|
| --- | --- | --- |
|
|
130
|
-
| `web.entry` | `./web/main.tsx` | The app frontend entrypoint lives somewhere else, such as
|
|
130
|
+
| `web.entry` | `./web/main.tsx` | The app frontend entrypoint lives somewhere else, such as `./frontend.tsx` |
|
|
131
131
|
| `web.shortName` | `appName` | The installed app label should be shorter than the full name |
|
|
132
132
|
| `web.themeColor` | `#111827` | Browser chrome/install metadata should match product branding |
|
|
133
133
|
| `web.backgroundColor` | `#ffffff` | The manifest background should match the app splash/background |
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { dirname } from "node:path";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export type BunCompileTarget =
|
|
5
5
|
| "bun-linux-x64"
|
|
@@ -13,6 +13,9 @@ export interface BuildWebAppBinaryOptions {
|
|
|
13
13
|
outfile: string;
|
|
14
14
|
target?: BunCompileTarget;
|
|
15
15
|
define?: Record<string, string>;
|
|
16
|
+
web?: {
|
|
17
|
+
entry?: string;
|
|
18
|
+
};
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function getBunCompileTargetFromArgs(argv = Bun.argv): BunCompileTarget | undefined {
|
|
@@ -22,21 +25,93 @@ export function getBunCompileTargetFromArgs(argv = Bun.argv): BunCompileTarget |
|
|
|
22
25
|
|
|
23
26
|
export async function buildWebAppBinary(options: BuildWebAppBinaryOptions): Promise<void> {
|
|
24
27
|
mkdirSync(dirname(options.outfile), { recursive: true });
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
const packageRoot = findPackageRoot(dirname(resolve(options.entrypoint)));
|
|
29
|
+
const buildId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
30
|
+
const cacheDir = resolve(packageRoot, ".cache", "webapp-build", buildId);
|
|
31
|
+
try {
|
|
32
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
33
|
+
const webEntry = resolve(dirname(resolve(options.entrypoint)), options.web?.entry ?? "./web/main.tsx");
|
|
34
|
+
const browserEntry = resolve(cacheDir, "webapp-browser-entry.ts");
|
|
35
|
+
const browserOutDir = resolve(cacheDir, "browser");
|
|
36
|
+
writeFileSync(browserEntry, `import { createRoot } from "react-dom/client";
|
|
37
|
+
import { configureWebAppRenderer } from "@pablozaiden/webapp/web";
|
|
38
|
+
|
|
39
|
+
configureWebAppRenderer(createRoot);
|
|
40
|
+
import ${JSON.stringify(webEntry)};
|
|
41
|
+
`);
|
|
42
|
+
const browserBuild = await Bun.build({
|
|
43
|
+
entrypoints: [browserEntry],
|
|
44
|
+
outdir: browserOutDir,
|
|
45
|
+
target: "browser",
|
|
46
|
+
minify: true,
|
|
47
|
+
sourcemap: "external",
|
|
48
|
+
define: options.define,
|
|
49
|
+
});
|
|
50
|
+
if (!browserBuild.success) {
|
|
51
|
+
for (const log of browserBuild.logs) {
|
|
52
|
+
console.error(log);
|
|
53
|
+
}
|
|
54
|
+
throw new Error("Browser build failed");
|
|
55
|
+
}
|
|
56
|
+
const assets = browserBuild.outputs
|
|
57
|
+
.map((output) => {
|
|
58
|
+
const ext = extname(output.path).toLowerCase();
|
|
59
|
+
const publicPath = `/webapp-compiled/${basename(output.path)}`;
|
|
60
|
+
return {
|
|
61
|
+
path: publicPath,
|
|
62
|
+
contentType: contentTypeForOutput(ext),
|
|
63
|
+
role: ext === ".css" ? "style" : ext === ".js" ? "script" : "asset",
|
|
64
|
+
body: readFileSync(output.path).toString("base64"),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
const compiledAssetsModule = resolve(cacheDir, "compiled-webapp-assets.ts");
|
|
68
|
+
writeFileSync(compiledAssetsModule, `globalThis[Symbol.for("webapp.compiledClient")] = ${JSON.stringify({ packageRoot, assets })};
|
|
69
|
+
`);
|
|
70
|
+
const compiledEntrypoint = resolve(cacheDir, "entrypoint.ts");
|
|
71
|
+
writeFileSync(compiledEntrypoint, `import "./compiled-webapp-assets";
|
|
72
|
+
import ${JSON.stringify(resolve(options.entrypoint))};
|
|
73
|
+
`);
|
|
74
|
+
const result = await Bun.build({
|
|
75
|
+
entrypoints: [compiledEntrypoint],
|
|
76
|
+
target: "bun",
|
|
77
|
+
minify: true,
|
|
78
|
+
sourcemap: "external",
|
|
79
|
+
define: options.define,
|
|
80
|
+
compile: options.target ? { target: options.target, outfile: options.outfile } : { outfile: options.outfile },
|
|
81
|
+
});
|
|
82
|
+
if (!result.success) {
|
|
83
|
+
for (const log of result.logs) {
|
|
84
|
+
console.error(log);
|
|
85
|
+
}
|
|
86
|
+
throw new Error("Binary build failed");
|
|
36
87
|
}
|
|
37
|
-
|
|
88
|
+
if (process.platform !== "win32" && !options.target?.startsWith("bun-windows")) {
|
|
89
|
+
chmodSync(options.outfile, 0o755);
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(cacheDir, { recursive: true, force: true });
|
|
38
93
|
}
|
|
39
|
-
|
|
40
|
-
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findPackageRoot(start: string): string {
|
|
97
|
+
let current = start;
|
|
98
|
+
while (true) {
|
|
99
|
+
if (existsSync(resolve(current, "package.json"))) {
|
|
100
|
+
return current;
|
|
101
|
+
}
|
|
102
|
+
const parent = dirname(current);
|
|
103
|
+
if (parent === current) return start;
|
|
104
|
+
current = parent;
|
|
41
105
|
}
|
|
42
106
|
}
|
|
107
|
+
|
|
108
|
+
function contentTypeForOutput(ext: string): string {
|
|
109
|
+
if (ext === ".js") return "text/javascript; charset=utf-8";
|
|
110
|
+
if (ext === ".css") return "text/css; charset=utf-8";
|
|
111
|
+
if (ext === ".svg") return "image/svg+xml; charset=utf-8";
|
|
112
|
+
if (ext === ".png") return "image/png";
|
|
113
|
+
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
114
|
+
if (ext === ".webp") return "image/webp";
|
|
115
|
+
if (ext === ".map") return "application/json; charset=utf-8";
|
|
116
|
+
return "application/octet-stream";
|
|
117
|
+
}
|
|
@@ -134,7 +134,7 @@ export interface WebAppIconsConfig {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
type WebDocument = {
|
|
137
|
-
bundle
|
|
137
|
+
bundle?: HtmlBundleIndex;
|
|
138
138
|
entryPublicPath: string;
|
|
139
139
|
cacheDir: string;
|
|
140
140
|
html: string;
|
|
@@ -143,6 +143,18 @@ type WebDocument = {
|
|
|
143
143
|
generatedPublicRoutes: Record<string, PublicRouteDefinition>;
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
type CompiledClientAsset = {
|
|
147
|
+
path: string;
|
|
148
|
+
contentType: string;
|
|
149
|
+
role: "script" | "style" | "asset";
|
|
150
|
+
body: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
type CompiledClient = {
|
|
154
|
+
packageRoot: string;
|
|
155
|
+
assets: CompiledClientAsset[];
|
|
156
|
+
};
|
|
157
|
+
|
|
146
158
|
const DEFAULT_WEB_ENTRY = "./web/main.tsx";
|
|
147
159
|
const DEFAULT_THEME_COLOR = "#111827";
|
|
148
160
|
const DEFAULT_BACKGROUND_COLOR = "#ffffff";
|
|
@@ -446,11 +458,32 @@ function iconConfig(value: string | URL | WebAppIconConfig | undefined): WebAppI
|
|
|
446
458
|
return typeof value === "object" && !(value instanceof URL) && "src" in value ? value : { src: value };
|
|
447
459
|
}
|
|
448
460
|
|
|
449
|
-
function
|
|
461
|
+
function compiledClient(): CompiledClient | undefined {
|
|
462
|
+
const value = (globalThis as { [key: symbol]: unknown })[Symbol.for("webapp.compiledClient")];
|
|
463
|
+
if (!value || typeof value !== "object") return undefined;
|
|
464
|
+
const candidate = value as Partial<CompiledClient>;
|
|
465
|
+
return typeof candidate.packageRoot === "string" && Array.isArray(candidate.assets) ? candidate as CompiledClient : undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function generatedHtml(
|
|
469
|
+
config: RuntimeConfig,
|
|
470
|
+
web: WebAppDocumentConfig,
|
|
471
|
+
relativeEntry: string | undefined,
|
|
472
|
+
relativePrelude: string | undefined,
|
|
473
|
+
themeColor: string,
|
|
474
|
+
faviconPath: string,
|
|
475
|
+
appleTouchPath: string,
|
|
476
|
+
compiledAssets?: CompiledClientAsset[],
|
|
477
|
+
): string {
|
|
450
478
|
const title = escapeHtml(web.title ?? config.appName);
|
|
451
479
|
const shortName = escapeAttribute(web.shortName ?? config.appName);
|
|
452
480
|
const htmlFaviconPath = faviconPath.replace(/^\//, "./");
|
|
453
481
|
const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
|
|
482
|
+
const styleTags = compiledAssets?.filter((asset) => asset.role === "style").map((asset) => ` <link rel="stylesheet" href="${escapeAttribute(asset.path)}" />`).join("\n") ?? "";
|
|
483
|
+
const scriptTags = compiledAssets
|
|
484
|
+
? compiledAssets.filter((asset) => asset.role === "script").map((asset) => ` <script type="module" src="${escapeAttribute(asset.path)}"></script>`).join("\n")
|
|
485
|
+
: ` <script type="module" src="${escapeAttribute(relativePrelude ?? "")}"></script>
|
|
486
|
+
<script type="module" src="${escapeAttribute(relativeEntry ?? "")}"></script>`;
|
|
454
487
|
const manifestTags = pwaEnabled(web)
|
|
455
488
|
? ` <link rel="icon" href="${escapeAttribute(htmlFaviconPath)}" />
|
|
456
489
|
<link rel="apple-touch-icon" href="${escapeAttribute(htmlAppleTouchPath)}" />
|
|
@@ -473,11 +506,11 @@ function generatedHtml(config: RuntimeConfig, web: WebAppDocumentConfig, relativ
|
|
|
473
506
|
<meta name="theme-color" content="${escapeAttribute(themeColor)}" />
|
|
474
507
|
${manifestTags} <title>${title}</title>
|
|
475
508
|
<script>${themeBootScript(themeColor)}</script>
|
|
509
|
+
${styleTags}
|
|
476
510
|
</head>
|
|
477
511
|
<body>
|
|
478
512
|
<div id="root"></div>
|
|
479
|
-
|
|
480
|
-
<script type="module" src="${escapeAttribute(relativeEntry)}"></script>
|
|
513
|
+
${scriptTags}
|
|
481
514
|
</body>
|
|
482
515
|
</html>
|
|
483
516
|
`;
|
|
@@ -485,11 +518,12 @@ ${manifestTags} <title>${title}</title>
|
|
|
485
518
|
|
|
486
519
|
async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocumentConfig | undefined): Promise<WebDocument> {
|
|
487
520
|
const web = webInput ?? {};
|
|
488
|
-
const
|
|
521
|
+
const compiled = compiledClient();
|
|
522
|
+
const entryFile = compiled ? undefined : resolveWebEntry(web.entry);
|
|
489
523
|
const themeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
|
|
490
524
|
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);
|
|
525
|
+
const packageRoot = compiled?.packageRoot ?? findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || (entryFile ?? "."))));
|
|
526
|
+
const publicEntry = entryFile ? webEntryPublicPath(entryFile, packageRoot) : "";
|
|
493
527
|
const cacheDir = createDocumentCacheDir(config.envPrefix);
|
|
494
528
|
const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
|
|
495
529
|
const icon = generatedIcon(config.appName, themeColor, backgroundColor);
|
|
@@ -521,8 +555,8 @@ import { configureWebAppRenderer } from ${JSON.stringify(frameworkWebPath)};
|
|
|
521
555
|
|
|
522
556
|
configureWebAppRenderer(createRoot);
|
|
523
557
|
`);
|
|
524
|
-
const relativeEntry = toWebPath(relative(cacheDir, entryFile));
|
|
525
|
-
const relativePrelude = toWebPath(relative(cacheDir, preludePath));
|
|
558
|
+
const relativeEntry = entryFile ? toWebPath(relative(cacheDir, entryFile)) : undefined;
|
|
559
|
+
const relativePrelude = entryFile ? toWebPath(relative(cacheDir, preludePath)) : undefined;
|
|
526
560
|
const faviconPath = favicon ? `/webapp-favicon${pathExtension(resolveWebAsset(favicon.src, packageRoot)) || ".png"}` : "/webapp-icon.svg";
|
|
527
561
|
const appleTouchPath = appleTouch ? `/webapp-apple-touch-icon${pathExtension(resolveWebAsset(appleTouch.src, packageRoot)) || ".png"}` : faviconPath;
|
|
528
562
|
if (favicon) {
|
|
@@ -538,18 +572,28 @@ configureWebAppRenderer(createRoot);
|
|
|
538
572
|
await copyWebAsset(manifestIconFile, resolve(cacheDir, `webapp-icon-${index + 1}${ext}`));
|
|
539
573
|
}
|
|
540
574
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
575
|
+
const compiledAssets = compiled?.assets;
|
|
576
|
+
writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, themeColor, faviconPath, appleTouchPath, compiledAssets));
|
|
577
|
+
const bundle = compiledAssets ? undefined : (await import(`${pathToFileURL(htmlPath).href}?v=${Date.now()}-${Math.random()}`)).default;
|
|
578
|
+
if (!compiledAssets && !isHtmlBundleIndex(bundle)) {
|
|
544
579
|
throw new Error("Generated web document did not produce a Bun HTMLBundle");
|
|
545
580
|
}
|
|
546
|
-
const html = await Bun.file(bundle.index).text();
|
|
581
|
+
const html = bundle ? await Bun.file(bundle.index).text() : await Bun.file(htmlPath).text();
|
|
547
582
|
const generatedPublicRoutes: Record<string, PublicRouteDefinition> = {
|
|
548
583
|
"/webapp-icon.svg": {
|
|
549
584
|
headers: { "content-type": "image/svg+xml; charset=utf-8" },
|
|
550
585
|
GET: icon,
|
|
551
586
|
},
|
|
552
587
|
};
|
|
588
|
+
if (compiledAssets) {
|
|
589
|
+
for (const asset of compiledAssets) {
|
|
590
|
+
const body = Buffer.from(asset.body, "base64");
|
|
591
|
+
generatedPublicRoutes[asset.path] = {
|
|
592
|
+
headers: { "content-type": asset.contentType },
|
|
593
|
+
GET: () => body,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
553
597
|
if (favicon) {
|
|
554
598
|
const faviconFile = resolveWebAsset(favicon.src, packageRoot);
|
|
555
599
|
generatedPublicRoutes[faviconPath] = {
|
|
@@ -718,10 +762,12 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
718
762
|
const configuredFavicon = iconConfig(input.web?.icons?.favicon);
|
|
719
763
|
const configuredAppleTouch = iconConfig(input.web?.icons?.appleTouch) ?? configuredFavicon;
|
|
720
764
|
const configuredManifestIcons = input.web?.icons?.manifest ?? [];
|
|
765
|
+
const compiled = compiledClient();
|
|
721
766
|
const webEntryFile = resolveWebEntry(input.web?.entry);
|
|
722
767
|
const webPackageRoot = findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || webEntryFile)));
|
|
723
768
|
const generatedRoutePaths = new Set([
|
|
724
769
|
webEntryPublicPath(webEntryFile, webPackageRoot),
|
|
770
|
+
...(compiled?.assets.map((asset) => asset.path) ?? []),
|
|
725
771
|
"/webapp-icon.svg",
|
|
726
772
|
...(configuredFavicon ? [`/webapp-favicon${pathExtension(resolveWebAsset(configuredFavicon.src, webPackageRoot)) || ".png"}`] : []),
|
|
727
773
|
...(configuredAppleTouch ? [`/webapp-apple-touch-icon${pathExtension(resolveWebAsset(configuredAppleTouch.src, webPackageRoot)) || ".png"}`] : []),
|
|
@@ -737,7 +783,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
737
783
|
throw new Error(`publicRoutes cannot override framework-owned web route: ${path}`);
|
|
738
784
|
}
|
|
739
785
|
}
|
|
740
|
-
if (hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
|
|
786
|
+
if (document.entryPublicPath && hasOwnPublicRoute(publicRoutes, document.entryPublicPath)) {
|
|
741
787
|
throw new Error(`publicRoutes cannot override framework-owned web route: ${document.entryPublicPath}`);
|
|
742
788
|
}
|
|
743
789
|
return document;
|
|
@@ -1302,22 +1348,26 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
1302
1348
|
DELETE: dynamicHandler,
|
|
1303
1349
|
OPTIONS: dynamicHandler,
|
|
1304
1350
|
};
|
|
1351
|
+
// Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
|
|
1352
|
+
// Wrapping it in a handler or Response, or adding route-level headers, serves
|
|
1353
|
+
// untransformed module paths and breaks generated document routes.
|
|
1354
|
+
const spaDocumentRoute = webDocument.bundle ? {
|
|
1355
|
+
...spaFallbackRoute,
|
|
1356
|
+
GET: webDocument.bundle as never,
|
|
1357
|
+
HEAD: webDocument.bundle as never,
|
|
1358
|
+
} : spaFallbackRoute;
|
|
1359
|
+
const entryRoute = webDocument.bundle ? { [webDocument.entryPublicPath]: webDocument.bundle as never } : {};
|
|
1305
1360
|
const server = Bun.serve<WebAppWebSocketData>({
|
|
1306
1361
|
hostname: config.host,
|
|
1307
1362
|
port: config.port,
|
|
1308
1363
|
routes: {
|
|
1309
1364
|
...publicRouteHandlers,
|
|
1310
|
-
|
|
1365
|
+
...entryRoute,
|
|
1311
1366
|
"/api/*": dynamicHandler,
|
|
1312
1367
|
"/.well-known/*": dynamicHandler,
|
|
1313
|
-
"/device": dynamicHandler,
|
|
1314
|
-
"/setup":
|
|
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
|
-
},
|
|
1368
|
+
"/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
|
|
1369
|
+
"/setup": spaDocumentRoute,
|
|
1370
|
+
"/*": spaDocumentRoute,
|
|
1321
1371
|
},
|
|
1322
1372
|
websocket: {
|
|
1323
1373
|
open(socket) {
|
package/src/web/api-client.ts
CHANGED
|
@@ -15,6 +15,10 @@ export class WebAppApiError extends Error {
|
|
|
15
15
|
export type AuthRequiredListener = () => void;
|
|
16
16
|
|
|
17
17
|
const authRequiredListeners = new Set<AuthRequiredListener>();
|
|
18
|
+
let configuredPublicBasePath: string | undefined;
|
|
19
|
+
let configuredApiBaseUrl: string | undefined;
|
|
20
|
+
let configuredWebSocketBaseUrl: string | undefined;
|
|
21
|
+
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
|
|
18
22
|
|
|
19
23
|
export function onAuthRequired(listener: AuthRequiredListener): () => void {
|
|
20
24
|
authRequiredListeners.add(listener);
|
|
@@ -27,20 +31,74 @@ function emitAuthRequired(): void {
|
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
export function configureWebAppClient(options: {
|
|
35
|
+
publicBasePath?: string | null;
|
|
36
|
+
apiBaseUrl?: string | null;
|
|
37
|
+
wsBaseUrl?: string | null;
|
|
38
|
+
} = {}): void {
|
|
39
|
+
setWebAppPublicBasePath(options.publicBasePath);
|
|
40
|
+
configuredApiBaseUrl = normalizeOptionalBaseUrl(options.apiBaseUrl);
|
|
41
|
+
configuredWebSocketBaseUrl = normalizeOptionalBaseUrl(options.wsBaseUrl);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setWebAppPublicBasePath(basePath?: string | null): void {
|
|
45
|
+
if (basePath == null) {
|
|
46
|
+
configuredPublicBasePath = undefined;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const normalizedBasePath = normalizePublicBasePath(basePath);
|
|
51
|
+
configuredPublicBasePath = normalizedBasePath || undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getWebAppPublicBasePath(): string {
|
|
55
|
+
return configuredPublicBasePath ?? "";
|
|
56
|
+
}
|
|
57
|
+
|
|
30
58
|
export function appPath(path: string): string {
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
59
|
+
if (isAbsolutePath(path)) return path;
|
|
60
|
+
if (configuredApiBaseUrl) {
|
|
61
|
+
return buildAbsoluteUrl(configuredApiBaseUrl, path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
65
|
+
const configuredBasePath = getWebAppPublicBasePath();
|
|
66
|
+
if (configuredBasePath) {
|
|
67
|
+
return new URL(normalizedPath, getConfiguredBaseUrl(configuredBasePath)).toString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return new URL(normalizedPath, getDocumentBaseUrl()).toString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function appAbsoluteUrl(path: string): string {
|
|
74
|
+
if (isAbsolutePath(path)) return path;
|
|
75
|
+
|
|
76
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
77
|
+
const configuredBasePath = getWebAppPublicBasePath();
|
|
78
|
+
if (configuredBasePath) {
|
|
79
|
+
return new URL(normalizedPath, getConfiguredBaseUrl(configuredBasePath)).toString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return new URL(normalizedPath, getDocumentBaseUrl()).toString();
|
|
34
83
|
}
|
|
35
84
|
|
|
36
85
|
export function appWebSocketUrl(path = "/api/ws"): string {
|
|
37
|
-
const
|
|
38
|
-
url
|
|
86
|
+
const configuredWsBaseUrl = configuredWebSocketBaseUrl ?? configuredApiBaseUrl;
|
|
87
|
+
const url = new URL(configuredWsBaseUrl ? buildAbsoluteUrl(configuredWsBaseUrl, path) : appAbsoluteUrl(path));
|
|
88
|
+
if (url.protocol === "http:") {
|
|
89
|
+
url.protocol = "ws:";
|
|
90
|
+
} else if (url.protocol === "https:") {
|
|
91
|
+
url.protocol = "wss:";
|
|
92
|
+
}
|
|
39
93
|
return url.toString();
|
|
40
94
|
}
|
|
41
95
|
|
|
96
|
+
export async function appRequest(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
97
|
+
return await fetch(typeof input === "string" ? appPath(input) : input, init);
|
|
98
|
+
}
|
|
99
|
+
|
|
42
100
|
export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
43
|
-
const response = await
|
|
101
|
+
const response = await appRequest(input, {
|
|
44
102
|
...init,
|
|
45
103
|
credentials: init?.credentials ?? "same-origin",
|
|
46
104
|
headers: {
|
|
@@ -62,3 +120,42 @@ export async function appFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
62
120
|
}
|
|
63
121
|
return response;
|
|
64
122
|
}
|
|
123
|
+
|
|
124
|
+
function normalizeOptionalBaseUrl(rawValue?: string | null): string | undefined {
|
|
125
|
+
const trimmedValue = rawValue?.trim();
|
|
126
|
+
if (!trimmedValue) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return trimmedValue.replace(/\/+$/, "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizePublicBasePath(rawBasePath?: string | null): string {
|
|
134
|
+
const trimmedBasePath = rawBasePath?.trim();
|
|
135
|
+
if (!trimmedBasePath || trimmedBasePath === "/") {
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const withLeadingSlash = trimmedBasePath.startsWith("/") ? trimmedBasePath : `/${trimmedBasePath}`;
|
|
140
|
+
const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, "");
|
|
141
|
+
|
|
142
|
+
return withoutTrailingSlash === "/" ? "" : withoutTrailingSlash;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildAbsoluteUrl(baseUrl: string, path: string): string {
|
|
146
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
147
|
+
return new URL(normalizedPath, `${baseUrl}/`).toString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getDocumentBaseUrl(): URL {
|
|
151
|
+
const baseHref = document.querySelector("base")?.getAttribute("href");
|
|
152
|
+
return baseHref ? new URL(baseHref, window.location.href) : new URL(".", window.location.href);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getConfiguredBaseUrl(basePath: string): URL {
|
|
156
|
+
return new URL(`${basePath}/`, new URL(window.location.href).origin);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isAbsolutePath(path: string): boolean {
|
|
160
|
+
return ABSOLUTE_URL_PATTERN.test(path) || path.startsWith("//");
|
|
161
|
+
}
|