@pablozaiden/webapp 0.5.4 → 0.5.6
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/server.md
CHANGED
|
@@ -129,7 +129,7 @@ For a functional PWA:
|
|
|
129
129
|
| --- | --- | --- |
|
|
130
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
|
-
| `web.themeColor` |
|
|
132
|
+
| `web.themeColor` | Not emitted unless set | Browser chrome/install metadata should match product branding; the generated default icon still uses `#111827` when unset |
|
|
133
133
|
| `web.backgroundColor` | `#ffffff` | The manifest background should match the app splash/background |
|
|
134
134
|
| `web.icons.favicon` | Generated initials SVG | Browser tabs should use product artwork instead of initials |
|
|
135
135
|
| `web.icons.appleTouch` | `favicon` or generated initials SVG | iOS home-screen/Dock should use product artwork |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,6 @@
|
|
|
61
61
|
"playwright": "1.61.0",
|
|
62
62
|
"react": "19.2.7",
|
|
63
63
|
"react-dom": "19.2.7",
|
|
64
|
-
"typescript": "
|
|
64
|
+
"typescript": "7.0.2"
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -208,6 +208,35 @@ function createRefreshRecord(userId: string, clientId: string, scope: string, fa
|
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
function revokeExistingClientSessions(store: WebAppStore, userId: string, clientId: string, revokedAt: string): void {
|
|
212
|
+
for (const session of store.listRefreshSessions(userId)) {
|
|
213
|
+
if (session.clientId === clientId && !session.revokedAt && !isExpired(session.expiresAt)) {
|
|
214
|
+
store.revokeRefreshSession(session.id, revokedAt, userId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function activeRefreshSessions(store: WebAppStore, userId: string): RefreshSessionRecord[] {
|
|
220
|
+
return store.listRefreshSessions(userId)
|
|
221
|
+
.filter((session) => !session.revokedAt && !isExpired(session.expiresAt))
|
|
222
|
+
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function uniqueActiveClientSessions(store: WebAppStore, userId: string): RefreshSessionRecord[] {
|
|
226
|
+
const seenClientIds = new Set<string>();
|
|
227
|
+
const sessions: RefreshSessionRecord[] = [];
|
|
228
|
+
const duplicateRevokedAt = nowIso();
|
|
229
|
+
for (const session of activeRefreshSessions(store, userId)) {
|
|
230
|
+
if (seenClientIds.has(session.clientId)) {
|
|
231
|
+
store.revokeRefreshSession(session.id, duplicateRevokedAt, userId);
|
|
232
|
+
} else {
|
|
233
|
+
seenClientIds.add(session.clientId);
|
|
234
|
+
sessions.push(session);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return sessions;
|
|
238
|
+
}
|
|
239
|
+
|
|
211
240
|
export async function exchangeDeviceCode(store: WebAppStore, config: RuntimeConfig, deviceCode: string, clientId?: string): Promise<TokenResponse> {
|
|
212
241
|
store.deleteExpiredDeviceAuthRequests(nowIso());
|
|
213
242
|
const record = store.getDeviceAuthByDeviceCodeHash(sha256(deviceCode));
|
|
@@ -237,6 +266,7 @@ export async function exchangeDeviceCode(store: WebAppStore, config: RuntimeConf
|
|
|
237
266
|
throw new AuthError("invalid_grant", "Approving user no longer exists", 400);
|
|
238
267
|
}
|
|
239
268
|
const user = toCurrentUser(userRecord);
|
|
269
|
+
revokeExistingClientSessions(store, user.id, record.clientId, nowIso());
|
|
240
270
|
const refresh = createRefreshRecord(user.id, record.clientId, record.scope);
|
|
241
271
|
store.saveRefreshSession(refresh.record);
|
|
242
272
|
const access = await issueAccessToken(store, config, {
|
|
@@ -305,8 +335,7 @@ export async function verifyAccessToken(store: WebAppStore, config: RuntimeConfi
|
|
|
305
335
|
|
|
306
336
|
export function listAuthSessions(store: WebAppStore, userId: string): AuthSessionSummary[] {
|
|
307
337
|
store.deleteExpiredRefreshSessions?.(nowIso());
|
|
308
|
-
return store
|
|
309
|
-
.filter((session) => !session.revokedAt && !isExpired(session.expiresAt))
|
|
338
|
+
return uniqueActiveClientSessions(store, userId)
|
|
310
339
|
.map((session) => ({
|
|
311
340
|
id: session.id,
|
|
312
341
|
clientId: session.clientId,
|
|
@@ -413,13 +413,15 @@ function contentTypeForIcon(path: string, explicit?: string): string {
|
|
|
413
413
|
return "application/octet-stream";
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
function themeBootScript(themeColor: string): string {
|
|
417
|
-
const
|
|
418
|
-
|
|
416
|
+
function themeBootScript(themeColor: string | undefined): string {
|
|
417
|
+
const themeColorUpdate = themeColor
|
|
418
|
+
? `
|
|
419
|
+
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
|
420
|
+
if (metaThemeColor instanceof HTMLMetaElement) metaThemeColor.content = resolved === "dark" ? ${JSON.stringify(themeColor)} : ${JSON.stringify(DEFAULT_BACKGROUND_COLOR)};`
|
|
421
|
+
: "";
|
|
419
422
|
return `(() => {
|
|
420
423
|
const key = "webapp.theme";
|
|
421
424
|
const root = document.documentElement;
|
|
422
|
-
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
|
423
425
|
const stored = window.localStorage.getItem(key);
|
|
424
426
|
const preference = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
|
|
425
427
|
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
@@ -428,7 +430,7 @@ function themeBootScript(themeColor: string): string {
|
|
|
428
430
|
root.style.colorScheme = resolved;
|
|
429
431
|
root.dataset.theme = preference;
|
|
430
432
|
root.dataset.resolvedTheme = resolved;
|
|
431
|
-
|
|
433
|
+
${themeColorUpdate}
|
|
432
434
|
})();`;
|
|
433
435
|
}
|
|
434
436
|
|
|
@@ -440,7 +442,7 @@ function pwaConfig(web: WebAppDocumentConfig): WebAppPwaConfig {
|
|
|
440
442
|
return typeof web.pwa === "object" ? web.pwa : {};
|
|
441
443
|
}
|
|
442
444
|
|
|
443
|
-
function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig,
|
|
445
|
+
function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig, backgroundColor: string, icons: WebAppIconConfig[]): string {
|
|
444
446
|
const pwa = pwaConfig(web);
|
|
445
447
|
return JSON.stringify({
|
|
446
448
|
name: config.appName,
|
|
@@ -449,7 +451,7 @@ function generatedManifest(config: RuntimeConfig, web: WebAppDocumentConfig, the
|
|
|
449
451
|
scope: pwa.scope ?? "./",
|
|
450
452
|
display: pwa.display ?? "standalone",
|
|
451
453
|
background_color: backgroundColor,
|
|
452
|
-
theme_color: themeColor,
|
|
454
|
+
...(web.themeColor ? { theme_color: web.themeColor } : {}),
|
|
453
455
|
icons,
|
|
454
456
|
}, null, 2);
|
|
455
457
|
}
|
|
@@ -471,7 +473,7 @@ function generatedHtml(
|
|
|
471
473
|
web: WebAppDocumentConfig,
|
|
472
474
|
relativeEntry: string | undefined,
|
|
473
475
|
relativePrelude: string | undefined,
|
|
474
|
-
themeColor: string,
|
|
476
|
+
themeColor: string | undefined,
|
|
475
477
|
faviconPath: string,
|
|
476
478
|
appleTouchPath: string,
|
|
477
479
|
compiledAssets?: CompiledClientAsset[],
|
|
@@ -480,6 +482,7 @@ function generatedHtml(
|
|
|
480
482
|
const shortName = escapeAttribute(web.shortName ?? config.appName);
|
|
481
483
|
const htmlFaviconPath = faviconPath.replace(/^\//, "./");
|
|
482
484
|
const htmlAppleTouchPath = appleTouchPath.replace(/^\//, "./");
|
|
485
|
+
const themeMetaTag = themeColor ? ` <meta name="theme-color" content="${escapeAttribute(themeColor)}" />\n` : "";
|
|
483
486
|
const styleTags = compiledAssets?.filter((asset) => asset.role === "style").map((asset) => ` <link rel="stylesheet" href="${escapeAttribute(asset.path)}" />`).join("\n") ?? "";
|
|
484
487
|
const scriptTags = compiledAssets
|
|
485
488
|
? compiledAssets
|
|
@@ -508,7 +511,7 @@ function generatedHtml(
|
|
|
508
511
|
<head>
|
|
509
512
|
<meta charset="utf-8" />
|
|
510
513
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
511
|
-
|
|
514
|
+
${themeMetaTag}\
|
|
512
515
|
${manifestTags} <title>${title}</title>
|
|
513
516
|
<script>${themeBootScript(themeColor)}</script>
|
|
514
517
|
${styleTags}
|
|
@@ -525,13 +528,13 @@ async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocument
|
|
|
525
528
|
const web = webInput ?? {};
|
|
526
529
|
const compiled = compiledClient();
|
|
527
530
|
const entryFile = compiled ? undefined : resolveWebEntry(web.entry);
|
|
528
|
-
const
|
|
531
|
+
const iconThemeColor = web.themeColor ?? DEFAULT_THEME_COLOR;
|
|
529
532
|
const backgroundColor = web.backgroundColor ?? DEFAULT_BACKGROUND_COLOR;
|
|
530
533
|
const packageRoot = compiled?.packageRoot ?? findPackageRoot(dirname(resolve(Bun.main || process.argv[1] || (entryFile ?? "."))));
|
|
531
534
|
const publicEntry = entryFile ? webEntryPublicPath(entryFile, packageRoot) : "";
|
|
532
535
|
const cacheDir = createDocumentCacheDir(config.envPrefix);
|
|
533
536
|
const htmlPath = resolve(cacheDir, `${config.envPrefix.toLowerCase()}-index.html`);
|
|
534
|
-
const icon = generatedIcon(config.appName,
|
|
537
|
+
const icon = generatedIcon(config.appName, iconThemeColor, backgroundColor);
|
|
535
538
|
const favicon = iconConfig(web.icons?.favicon);
|
|
536
539
|
const appleTouch = iconConfig(web.icons?.appleTouch) ?? favicon;
|
|
537
540
|
const manifestIconConfigs = web.icons?.manifest?.length ? web.icons.manifest : undefined;
|
|
@@ -547,7 +550,7 @@ async function createWebDocument(config: RuntimeConfig, webInput: WebAppDocument
|
|
|
547
550
|
};
|
|
548
551
|
})
|
|
549
552
|
: [{ src: "./webapp-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }];
|
|
550
|
-
const manifest = pwaEnabled(web) ? generatedManifest(config, web,
|
|
553
|
+
const manifest = pwaEnabled(web) ? generatedManifest(config, web, backgroundColor, manifestIcons) : "";
|
|
551
554
|
writeFileSync(resolve(cacheDir, "webapp-icon.svg"), icon);
|
|
552
555
|
if (manifest) {
|
|
553
556
|
writeFileSync(resolve(cacheDir, "site.webmanifest"), manifest);
|
|
@@ -578,7 +581,7 @@ configureWebAppRenderer(createRoot);
|
|
|
578
581
|
}
|
|
579
582
|
}
|
|
580
583
|
const compiledAssets = compiled?.assets;
|
|
581
|
-
writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, themeColor, faviconPath, appleTouchPath, compiledAssets));
|
|
584
|
+
writeFileSync(htmlPath, generatedHtml(config, web, relativeEntry, relativePrelude, web.themeColor, faviconPath, appleTouchPath, compiledAssets));
|
|
582
585
|
const bundle = compiledAssets ? undefined : (await import(`${pathToFileURL(htmlPath).href}?v=${Date.now()}-${Math.random()}`)).default;
|
|
583
586
|
if (!compiledAssets && !isHtmlBundleIndex(bundle)) {
|
|
584
587
|
throw new Error("Generated web document did not produce a Bun HTMLBundle");
|