/** * cli:scaffold-pwa — generate.ts * * PURE: takes the validated spec + the CURRENT content of the patch targets * (read by index.ts and passed in as a map — generate never touches disk) and * returns GeneratedFile[]: * * - src/pwa/sw.ts overwrite (index honours @customised) * - src/pwa/cacheKey.ts overwrite (index honours @customised) * - src/pwa/swMessages.ts overwrite (index honours @customised) * - public/icons/*.png skip-if-exists, base64 (placeholder) * - src/components/pwa/OutboxStatusChip.tsx skip-if-exists (dev-owned) * - vite.config.ts / src/main.tsx / src/App.tsx / index.html / package.json / * src/vite-env.d.ts patch (only emitted when changed) * * The service worker AND its cache-key/messaging modules are EMBEDDED here as * template strings — faithful copies of the socle's * web/smartstack-web/src/pwa/{sw,cacheKey,swMessages}.ts (never read from the * socle at CLI runtime). Byte-faithful below the AUTO-GENERATED banner EXCEPT * the push-notification fallback title, which becomes the manifest name. */ import { mainTsxSnippet, mergePackageJson, patchAppTsx, patchIndexHtml, patchMainTsx, patchViteConfig, patchViteEnv, vitePwaSnippet, type ManifestConfig, } from './patchers.js' import { encodePng } from './png.js' import type { GeneratedFile, ScaffoldPwaInput } from './types.js' // ════════════════════════════════════════════════════════════════════════════ // Manifest resolution // ════════════════════════════════════════════════════════════════════════════ export const DEFAULT_THEME_COLOR = '#4f46e5' export const DEFAULT_BACKGROUND_COLOR = '#111827' /** 'crm-app' → 'Crm App'. */ export function humaniseAppCode(appCode: string): string { return appCode .split('-') .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ') } /** * The src/index.css tokens the manifest theme_color is read from, in priority * order. The PLATFORM accent ramp comes first: scaffold-theme emits * `--color-accent-300..700` on every generated app, whereas * `--sl-color-primary-500` only exists when the app opted into Shoelace * (`useShoelace: true`). Probing Shoelace first meant every non-Shoelace app — * the default — silently fell through to the generic indigo default while its * own brand color sat right there in index.css. * * 600 before 500: the manifest theme_color paints the OS status bar / task * switcher chrome, where the one-step-darker shade holds contrast against white * status-bar text. 500 is the fallback when a theme only defines the base. */ export const THEME_COLOR_TOKENS = [ '--color-accent-600', '--color-accent-500', '--sl-color-primary-500', ] as const /** `#abc` → `#aabbcc`; `#AABBCC` → `#aabbcc`. */ function normaliseHex(hex: string): string { const h = hex.toLowerCase() return h.length === 4 ? `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}` : h } /** * themeColor resolution: spec → the client's own brand token in src/index.css * (THEME_COLOR_TOKENS, in order) → socle default. Graceful fallthrough — a * missing/unparseable index.css, or a token aliased to another `var(…)` rather * than a literal hex, just moves on to the next candidate. */ export function resolveThemeColor(spec: ScaffoldPwaInput, indexCss?: string): string { if (spec.manifest.themeColor) return spec.manifest.themeColor if (indexCss) { for (const token of THEME_COLOR_TOKENS) { const m = new RegExp(`${token}:\\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3})\\b`).exec(indexCss) if (m) return normaliseHex(m[1]) } } return DEFAULT_THEME_COLOR } export function resolveManifest(spec: ScaffoldPwaInput, indexCss?: string): ManifestConfig { const name = spec.manifest.name ?? humaniseAppCode(spec.appCode) const shortName = spec.manifest.shortName ?? (name.length <= 12 ? name : name.slice(0, 12).trimEnd()) return { name, shortName, description: spec.manifest.description ?? `${name} — SmartStack progressive web app`, themeColor: resolveThemeColor(spec, indexCss), backgroundColor: spec.manifest.backgroundColor ?? DEFAULT_BACKGROUND_COLOR, lang: spec.manifest.lang, } } // ════════════════════════════════════════════════════════════════════════════ // Service worker (embedded socle copy) // ════════════════════════════════════════════════════════════════════════════ /** * Faithful copy of the socle service worker (web/smartstack-web/src/pwa/sw.ts, * features/pwa, a6b9f2a5+) with ONE substitution: the push fallback title * 'SmartStack' → the manifest name. The cache-key derivation lives in * ./cacheKey (emitted via CACHE_KEY_SOURCE): X-Tenant-Slug / Accept-Language / * X-User-Id fold into __ss_tenant / __ss_lang / __ss_user, and the * PURGE_API_CACHE message drops the whole smartstack-api cache on login/logout. * The navigation denylist, the /api/auth/ exclusion, SKIP_WAITING, NetworkFirst * 4s and ExpirationPlugin(200, 24h) stay in the SW itself. All of it is * SACRED — audit rule DEV-PWA-003 greps its 12 markers on the CONCATENATION of * src/pwa/sw.ts + src/pwa/cacheKey.ts. */ export function swSource(manifestName: string): string { const title = manifestName.replace(/\\/g, '\\\\').replace(/'/g, "\\'") return `/// /** * AUTO-GENERATED by the frontend-pwa skill (scaffold-pwa CLI) — faithful copy * of the socle service worker (web/smartstack-web/src/pwa/sw.ts). * Re-run the CLI to refresh. Mark the file head "// @customised" to opt out of * regeneration (you then own every future socle fix yourself). * * Two AUTHORIZED deviations from the socle copy (cli-app-sync: expected): * - the push-notification default title is interpolated from the app manifest; * - the same-origin static-asset CacheFirst net (below) — the socle template * predates it; upstreaming it into web/smartstack-web/src/pwa/sw.ts is the * follow-up, until then the generated app is the more defensive of the two. * * SACRED — the tenant/language/user cache-key isolation (cacheKeyWillBeUsed → * buildApiCacheKey in ./cacheKey: X-Tenant-Slug / Accept-Language / X-User-Id * -> __ss_tenant / __ss_lang / __ss_user) is guarded by audit rules * DEV-PWA-005/DEV-PWA-003 across sw.ts + cacheKey.ts. Weakening it leaks one * tenant's (or one account's) cached data into another after a switch. * * Hard rules (see docs/architecture — mobile PWA): * - \`/hubs/*\` is NEVER intercepted (SignalR websocket/SSE negotiation). * - \`/api/auth/*\` is NEVER cached (tokens, session probes). * - Non-GET requests are NEVER cached. * - The API cache key includes the \`X-Tenant-Slug\`, \`Accept-Language\` and * \`X-User-Id\` request headers: all three vary the response without varying * the URL — a URL-only key would leak one tenant's (or one account's) data * into another after a switch, or serve the menu in the wrong language. * - The whole API cache is dropped on the PURGE_API_CACHE message (posted by * the app on login/logout) so a shared machine never accumulates another * account's responses. * - Updates activate ONLY on the SKIP_WAITING message (prompt flow) — * an automatic reload could destroy an in-progress offline entry. */ import { precacheAndRoute, cleanupOutdatedCaches, createHandlerBoundToURL, type PrecacheEntry } from 'workbox-precaching'; import { NavigationRoute, registerRoute } from 'workbox-routing'; import { NetworkFirst, CacheFirst } from 'workbox-strategies'; import { ExpirationPlugin } from 'workbox-expiration'; import { clientsClaim } from 'workbox-core'; import { API_CACHE_NAME, PURGE_API_CACHE_MESSAGE, buildApiCacheKey } from './cacheKey'; declare let self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array; }; // ── Precache (build manifest injected by vite-plugin-pwa) ────────────────── cleanupOutdatedCaches(); precacheAndRoute(self.__WB_MANIFEST); // Prompt-update flow: the waiting worker activates only when the user // accepts the UpdateBanner (the app posts SKIP_WAITING). // PURGE_API_CACHE (login/logout) drops the whole API runtime cache. self.addEventListener('message', event => { if (event.data?.type === 'SKIP_WAITING') { void self.skipWaiting(); } if (event.data?.type === PURGE_API_CACHE_MESSAGE) { event.waitUntil(caches.delete(API_CACHE_NAME)); } }); clientsClaim(); // ── SPA navigation fallback ──────────────────────────────────────────────── // Navigations render the cached index.html (offline app shell). API, hubs // and real files (SW itself, icons) are excluded. registerRoute( new NavigationRoute(createHandlerBoundToURL('index.html'), { denylist: [/^\\/api\\//, /^\\/hubs\\//], }) ); // ── Runtime cache: GET /api/* → NetworkFirst (4 s), tenant+language+user-aware ── registerRoute( ({ url, request }) => request.method === 'GET' && url.origin === self.location.origin && url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/auth/'), new NetworkFirst({ cacheName: API_CACHE_NAME, networkTimeoutSeconds: 4, plugins: [ new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 }), { cacheKeyWillBeUsed: async ({ request }) => buildApiCacheKey(request.url, request.headers), }, ], }) ); // ── Runtime safety net: same-origin static assets → CacheFirst ───────────── // A chunk larger than maximumFileSizeToCacheInBytes is EXCLUDED from the // precache by Workbox with only a BUILD WARNING — without this route an // oversized entry chunk means a BLANK SCREEN offline (the precache is the // only other asset source). Destination-based matching (script/style/font/ // image) plus the /api/ + /hubs/ exclusions keeps API-served media on the // tenant-keyed NetworkFirst cache above (an has // destination 'image': the path guard is what prevents a tenant-blind // CacheFirst copy). Vite asset URLs are content-hashed, so CacheFirst is // correct: a URL's bytes never change — a new deploy ships new hashes and the // prompt-update flow refreshes index.html + the precache. ExpirationPlugin // bounds the cache inside the ~50 MB iOS quota; purgeOnQuotaError marks it // first-to-drop under pressure. const ASSETS_CACHE_NAME = 'smartstack-assets'; const ASSET_DESTINATIONS = ['script', 'style', 'font', 'image']; registerRoute( ({ url, request }) => request.method === 'GET' && url.origin === self.location.origin && !url.pathname.startsWith('/api/') && !url.pathname.startsWith('/hubs/') && ASSET_DESTINATIONS.includes(request.destination), new CacheFirst({ cacheName: ASSETS_CACHE_NAME, plugins: [ new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60, purgeOnQuotaError: true }), ], }) ); // ── Web Push (backend lands in P2 — handlers are forward-ready) ──────────── interface PushPayload { title?: string; body?: string; url?: string; tag?: string; } self.addEventListener('push', event => { let payload: PushPayload = {}; try { payload = (event.data?.json() as PushPayload) ?? {}; } catch { payload = { body: event.data?.text() }; } event.waitUntil( self.registration.showNotification(payload.title ?? '${title}', { body: payload.body, tag: payload.tag, icon: '/icons/pwa-192.png', badge: '/icons/pwa-192.png', data: { url: payload.url ?? '/' }, }) ); }); self.addEventListener('notificationclick', event => { event.notification.close(); const targetUrl: string = (event.notification.data?.url as string) ?? '/'; event.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { for (const client of clientList) { if ('focus' in client) { void client.navigate(targetUrl); return client.focus(); } } return self.clients.openWindow(targetUrl); }) ); }); ` } /** * Faithful copy of the socle's web/smartstack-web/src/pwa/cacheKey.ts * (features/pwa, a6b9f2a5+), byte-faithful below the AUTO-GENERATED banner — * the pure cache-key derivation sw.ts imports. Losing it strands the SW import * AND drops the tenant/lang/user markers DEV-PWA-003 greps on the * sw.ts + cacheKey.ts concatenation. */ export const CACHE_KEY_SOURCE = `/** * AUTO-GENERATED by the frontend-pwa skill (scaffold-pwa CLI) — faithful copy * of the socle's web/smartstack-web/src/pwa/cacheKey.ts. Re-run the CLI to * refresh. Mark the file head "// @customised" to opt out of regeneration. */ /** * API runtime-cache key derivation, shared with the service worker. * * The key must vary on every request dimension that varies the response * without varying the URL — otherwise one context's data leaks into * another when the cache is read back: * - \`X-Tenant-Slug\`: multi-tenant data under the same route * - \`Accept-Language\`: localized payloads (menu, labels) * - \`X-User-Id\`: user-scoped payloads; without it, two accounts sharing * the same browser would read each other's cached responses. * * Kept as a pure function (no workbox / SW globals) so it is unit-testable. */ /** Minimal read surface of the Fetch \`Headers\` interface. */ interface HeaderReader { get(name: string): string | null; } export const API_CACHE_NAME = 'smartstack-api'; /** Message type the app posts to the SW to drop the whole API cache. */ export const PURGE_API_CACHE_MESSAGE = 'PURGE_API_CACHE'; export function buildApiCacheKey(url: string, headers: HeaderReader): string { const tenant = headers.get('X-Tenant-Slug') ?? 'no-tenant'; const language = headers.get('Accept-Language') ?? 'default'; const user = headers.get('X-User-Id') ?? 'anonymous'; const keyed = new URL(url); keyed.searchParams.set('__ss_tenant', tenant); keyed.searchParams.set('__ss_lang', language); keyed.searchParams.set('__ss_user', user); return keyed.href; } ` /** * Faithful copy of the socle's web/smartstack-web/src/pwa/swMessages.ts * (features/pwa, a6b9f2a5+), byte-faithful below the AUTO-GENERATED banner. * The app-side purgeApiCache() caller (login/logout/terminal 401) ships in the * @atlashub/smartstack package — this module is emitted so client code and * extensions post the purge through the same helper instead of hand-rolling * postMessage. */ export const SW_MESSAGES_SOURCE = `/** * AUTO-GENERATED by the frontend-pwa skill (scaffold-pwa CLI) — faithful copy * of the socle's web/smartstack-web/src/pwa/swMessages.ts. Re-run the CLI to * refresh. Mark the file head "// @customised" to opt out of regeneration. */ /** * App → service-worker messaging helpers. * * All helpers are no-ops when no SW controls the page (dev server, first * visit before activation, browsers without SW support) — callers never * need to guard. */ import { PURGE_API_CACHE_MESSAGE } from './cacheKey'; /** * Drop the whole \`smartstack-api\` runtime cache. Called on login and on * logout/terminal auth failure: the cache key includes the user identity, * but purging on identity change keeps a shared machine from accumulating * other accounts' responses at all. */ export function purgeApiCache(): void { try { navigator.serviceWorker?.controller?.postMessage({ type: PURGE_API_CACHE_MESSAGE }); } catch { // Messaging must never break the auth flow. } } ` // ════════════════════════════════════════════════════════════════════════════ // OutboxStatusChip (dev-owned, skip-if-exists) // ════════════════════════════════════════════════════════════════════════════ /** * Compiles against the REAL package API — useOutboxStatus(): OutboxStatusHandle * = OutboxStatus { pending, inflight, failed, conflict, records } + retry(id?), * discard(id), retryAll(). scaffold-component mounts it as: * */ const OUTBOX_STATUS_CHIP = `/* Scaffolded by frontend-pwa (scaffold-pwa) — dev-owned, editable. Re-running the CLI preserves this file (skip-if-exists). */ import type { ReactElement } from 'react' import { AlertOctagon, AlertTriangle, CloudUpload, RefreshCw } from 'lucide-react' import { useOutboxStatus } from '@atlashub/smartstack' export interface OutboxStatusChipLabels { pending: string failed: string conflict: string } export interface OutboxStatusChipProps { /** * componentKey root of the entity (e.g. 'app.module.section') — the SAME key * the entity's outbox specs use as their type root. When omitted, the chip * shows GLOBAL queue counts. */ resourceKey?: string labels?: OutboxStatusChipLabels } const DEFAULT_LABELS: OutboxStatusChipLabels = { pending: 'Pending', failed: 'Failed', conflict: 'Conflict', } /** * Compact offline-write queue chip for list/detail headers. Renders NOTHING * while the scoped queue is empty; token-styled (no hardcoded colors). */ export function OutboxStatusChip({ resourceKey, labels = DEFAULT_LABELS }: OutboxStatusChipProps): ReactElement | null { const outbox = useOutboxStatus() // Scope the queue to this resource: a spec type matches when it IS the // resource key or a sub-key of it ('app.module.section.create', ...). const records = resourceKey ? outbox.records.filter((r) => r.type === resourceKey || r.type.startsWith(resourceKey + '.')) : outbox.records // Queued = pending + inflight (both are on their way to the server). const pending = records.filter((r) => r.status === 'pending' || r.status === 'inflight').length const failed = records.filter((r) => r.status === 'failed').length const conflict = records.filter((r) => r.status === 'conflict').length if (pending + failed + conflict === 0) return null const retryable = records.filter((r) => r.status === 'failed' || r.status === 'conflict') const onRetryAll = (): void => { if (!resourceKey) { outbox.retryAll() return } // Scoped retry: re-queue only THIS resource's failed/conflict records. for (const r of retryable) outbox.retry(r.id) } return ( {pending > 0 && ( )} {failed > 0 && ( )} {conflict > 0 && ( )} {retryable.length > 0 && ( )} ) } ` // ════════════════════════════════════════════════════════════════════════════ // generate // ════════════════════════════════════════════════════════════════════════════ /** Current content of the patch targets, read by index.ts from the web root. * `undefined` = the file does not exist. */ export interface TargetSources { viteConfig?: string mainTsx?: string appTsx?: string indexHtml?: string packageJson?: string viteEnv?: string /** For themeColor resolution only — never patched. */ indexCss?: string } export interface GenerateResult { files: GeneratedFile[] warnings: string[] /** Full snippets for anchors the patchers could not find (→ nextSteps). */ manualSnippets: string[] manifest: ManifestConfig } const ICON_FILES: Array<{ path: string; size: number }> = [ { path: 'public/icons/pwa-192.png', size: 192 }, { path: 'public/icons/pwa-512.png', size: 512 }, // Solid fill ⇒ the maskable safe zone (inner 80%) is trivially satisfied. { path: 'public/icons/pwa-512-maskable.png', size: 512 }, { path: 'public/icons/apple-touch-icon.png', size: 180 }, ] export function generate(spec: ScaffoldPwaInput, targets: TargetSources): GenerateResult { const manifest = resolveManifest(spec, targets.indexCss) const files: GeneratedFile[] = [] const warnings: string[] = [] const manualSnippets: string[] = [] // ── Service worker + its cache-key/messaging modules (socle copies) ──────── files.push({ path: 'src/pwa/sw.ts', content: swSource(manifest.name), strategy: 'overwrite' }) files.push({ path: 'src/pwa/cacheKey.ts', content: CACHE_KEY_SOURCE, strategy: 'overwrite' }) files.push({ path: 'src/pwa/swMessages.ts', content: SW_MESSAGES_SOURCE, strategy: 'overwrite' }) // ── Placeholder icons (solid themeColor fill) ────────────────────────────── if (spec.icons === 'placeholder') { for (const icon of ICON_FILES) { files.push({ path: icon.path, content: encodePng(icon.size, icon.size, manifest.themeColor).toString('base64'), encoding: 'base64', strategy: 'skip-if-exists', }) } } // ── Dev-owned OutboxStatusChip ───────────────────────────────────────────── files.push({ path: 'src/components/pwa/OutboxStatusChip.tsx', content: OUTBOX_STATUS_CHIP, strategy: 'skip-if-exists', }) // ── Patches (only emitted when changed) ──────────────────────────────────── // vite.config.ts if (targets.viteConfig === undefined) { warnings.push('vite.config.ts not found at the web root — VitePWA wiring skipped.') manualSnippets.push(`Add to vite.config.ts (import + plugins array):\nimport { VitePWA } from 'vite-plugin-pwa'\n\n${vitePwaSnippet(manifest, ' ', spec.precacheMaxMiB)}`) } else { const r = patchViteConfig(targets.viteConfig, manifest, spec.precacheMaxMiB) if (r.warning) { warnings.push(`[vite.config.ts] ${r.warning}`) manualSnippets.push(`Add to vite.config.ts (import + plugins array):\nimport { VitePWA } from 'vite-plugin-pwa'\n\n${vitePwaSnippet(manifest, ' ', spec.precacheMaxMiB)}`) } if (r.changed) files.push({ path: 'vite.config.ts', content: r.content, strategy: 'patch' }) } // src/main.tsx if (targets.mainTsx === undefined) { warnings.push('src/main.tsx not found at the web root — registerSW/initOutbox wiring skipped.') manualSnippets.push(mainTsxSnippet({ mobile: spec.mobile })) } else { const r = patchMainTsx(targets.mainTsx, { mobile: spec.mobile }) if (r.warning) { warnings.push(`[src/main.tsx] ${r.warning}`) manualSnippets.push(mainTsxSnippet({ mobile: spec.mobile })) } if (r.changed) files.push({ path: 'src/main.tsx', content: r.content, strategy: 'patch' }) } // src/App.tsx if (targets.appTsx === undefined) { warnings.push('src/App.tsx not found at the web root — mount and manually next to .') } else { const r = patchAppTsx(targets.appTsx) if (r.warning) warnings.push(`[src/App.tsx] ${r.warning}`) if (r.changed) files.push({ path: 'src/App.tsx', content: r.content, strategy: 'patch' }) } // index.html if (targets.indexHtml === undefined) { warnings.push('index.html not found at the web root — add the PWA meta tags manually.') } else { const r = patchIndexHtml(targets.indexHtml, manifest) if (r.warning) warnings.push(`[index.html] ${r.warning}`) if (r.changed) files.push({ path: 'index.html', content: r.content, strategy: 'patch' }) } // package.json if (targets.packageJson === undefined) { warnings.push('package.json not found at the web root — add the PWA devDependencies manually (vite-plugin-pwa + workbox-*).') } else { const r = mergePackageJson(targets.packageJson) if (r.warning) warnings.push(`[package.json] ${r.warning}`) if (r.changed) files.push({ path: 'package.json', content: r.content, strategy: 'patch' }) } // src/vite-env.d.ts — when absent, seed a fresh file with the vite ref too. { const base = targets.viteEnv ?? '/// \n' const r = patchViteEnv(base) if (r.changed) files.push({ path: 'src/vite-env.d.ts', content: r.content, strategy: 'patch' }) } return { files, warnings, manualSnippets, manifest } }