@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.
@@ -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 Clanky's `./frontend.tsx` |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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": 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
- },
1321
+ "/device": deviceAuthEnabled ? spaDocumentRoute : dynamicHandler,
1322
+ "/setup": spaDocumentRoute,
1323
+ "/*": spaDocumentRoute,
1321
1324
  },
1322
1325
  websocket: {
1323
1326
  open(socket) {
@@ -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 (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path;
32
- const base = document.querySelector("base")?.getAttribute("href") ?? "/";
33
- return new URL(path.replace(/^\/+/, ""), new URL(base, window.location.href)).toString();
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 url = new URL(appPath(path));
38
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
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 fetch(typeof input === "string" ? appPath(input) : input, {
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
+ }