@pablozaiden/webapp 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/getting-started.md +26 -0
- package/docs/server.md +1 -1
- package/package.json +1 -1
- package/src/server/create-web-app-server.ts +11 -8
- 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
|
@@ -1302,6 +1302,14 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
1302
1302
|
DELETE: dynamicHandler,
|
|
1303
1303
|
OPTIONS: dynamicHandler,
|
|
1304
1304
|
};
|
|
1305
|
+
// Bun only transforms HTMLBundle modules/HMR when the bundle is mounted directly.
|
|
1306
|
+
// Wrapping it in a handler or Response, or adding route-level headers, serves
|
|
1307
|
+
// untransformed module paths and breaks generated document routes.
|
|
1308
|
+
const spaDocumentRoute = {
|
|
1309
|
+
...spaFallbackRoute,
|
|
1310
|
+
GET: webDocument.bundle as never,
|
|
1311
|
+
HEAD: webDocument.bundle as never,
|
|
1312
|
+
};
|
|
1305
1313
|
const server = Bun.serve<WebAppWebSocketData>({
|
|
1306
1314
|
hostname: config.host,
|
|
1307
1315
|
port: config.port,
|
|
@@ -1310,14 +1318,9 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
1310
1318
|
[webDocument.entryPublicPath]: webDocument.bundle as never,
|
|
1311
1319
|
"/api/*": dynamicHandler,
|
|
1312
1320
|
"/.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
|
-
},
|
|
1321
|
+
"/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
|
|
1322
|
+
"/setup": spaDocumentRoute,
|
|
1323
|
+
"/*": spaDocumentRoute,
|
|
1321
1324
|
},
|
|
1322
1325
|
websocket: {
|
|
1323
1326
|
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
|
+
}
|