/**
* 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 && (
{labels.pending} · {pending}
)}
{failed > 0 && (
{labels.failed} · {failed}
)}
{conflict > 0 && (
{labels.conflict} · {conflict}
)}
{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 }
}