diff --git a/README.md b/README.md index 01e18be..c46dec1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,13 @@ The repo also includes a multiplatform SwiftUI client in `apple-client/` for mac - Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`. - A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it. +The backend also exposes the latest Angular browser build through an API that native clients can sync into their local `WKWebView` bundle cache: + +- `GET /api/web-app/manifest`: Returns a bundle manifest with a stable `bundleId`, latest `generatedAt` timestamp, and a file list containing relative paths, SHA-256 hashes, MIME types, sizes, and download URLs. +- `GET /api/web-app/files/`: Streams an individual file from `client/dist/client/browser` with `ETag` and `Last-Modified` headers for native caching. + +If the Angular build does not exist yet, those endpoints return `404`. + ## Backend environment The backend accepts these environment variables: diff --git a/client/public/apple-touch-icon.png b/client/public/apple-touch-icon.png new file mode 100644 index 0000000..fea2d8e Binary files /dev/null and b/client/public/apple-touch-icon.png differ diff --git a/client/public/icon-source.svg b/client/public/icon-source.svg new file mode 100644 index 0000000..0f80470 --- /dev/null +++ b/client/public/icon-source.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/manifest.webmanifest b/client/public/manifest.webmanifest new file mode 100644 index 0000000..79636d7 --- /dev/null +++ b/client/public/manifest.webmanifest @@ -0,0 +1,40 @@ +{ + "id": "/", + "name": "PrivateChat", + "short_name": "PrivateChat", + "description": "Private peer-to-peer chat with Angular, Fastify, and WebRTC.", + "lang": "en", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#08111d", + "theme_color": "#08111d", + "icons": [ + { + "src": "pwa-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "pwa-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "maskable-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/client/public/maskable-192x192.png b/client/public/maskable-192x192.png new file mode 100644 index 0000000..874f5d9 Binary files /dev/null and b/client/public/maskable-192x192.png differ diff --git a/client/public/maskable-512x512.png b/client/public/maskable-512x512.png new file mode 100644 index 0000000..e17157c Binary files /dev/null and b/client/public/maskable-512x512.png differ diff --git a/client/public/pwa-192x192.png b/client/public/pwa-192x192.png new file mode 100644 index 0000000..874f5d9 Binary files /dev/null and b/client/public/pwa-192x192.png differ diff --git a/client/public/pwa-512x512.png b/client/public/pwa-512x512.png new file mode 100644 index 0000000..a6ac72c Binary files /dev/null and b/client/public/pwa-512x512.png differ diff --git a/client/public/service-worker.js b/client/public/service-worker.js new file mode 100644 index 0000000..ac3537f --- /dev/null +++ b/client/public/service-worker.js @@ -0,0 +1,77 @@ +const APP_SHELL_CACHE = 'privatechat-app-shell-v1'; +const APP_SHELL_FILES = [ + '/', + '/index.html', + '/manifest.webmanifest', + '/favicon.ico', + '/apple-touch-icon.png', + '/pwa-192x192.png', + '/pwa-512x512.png', + '/maskable-192x192.png', + '/maskable-512x512.png', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(APP_SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL_FILES)), + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => Promise.all( + cacheNames + .filter((cacheName) => cacheName !== APP_SHELL_CACHE) + .map((cacheName) => caches.delete(cacheName)), + )), + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + + if (request.method !== 'GET') { + return; + } + + const url = new URL(request.url); + + if (url.origin !== self.location.origin || url.pathname.startsWith('/api/') || url.pathname === '/ws') { + return; + } + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const responseCopy = response.clone(); + void caches.open(APP_SHELL_CACHE).then((cache) => cache.put('/index.html', responseCopy)); + return response; + }) + .catch(async () => { + const cache = await caches.open(APP_SHELL_CACHE); + return cache.match('/index.html') || Response.error(); + }), + ); + return; + } + + event.respondWith( + caches.match(request).then((cachedResponse) => { + const networkFetch = fetch(request) + .then((response) => { + if (response.ok) { + const responseCopy = response.clone(); + void caches.open(APP_SHELL_CACHE).then((cache) => cache.put(request, responseCopy)); + } + + return response; + }) + .catch(() => cachedResponse || Response.error()); + + return cachedResponse || networkFetch; + }), + ); +}); diff --git a/client/src/index.html b/client/src/index.html index 574cb26..679e609 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -5,6 +5,13 @@ PrivateChat + + + + + + + diff --git a/client/src/main.ts b/client/src/main.ts index 5df75f9..37cd657 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -2,5 +2,13 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; +if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.isSecureContext) { + window.addEventListener('load', () => { + void navigator.serviceWorker.register('/service-worker.js').catch((error) => { + console.error('Service worker registration failed.', error); + }); + }); +} + bootstrapApplication(App, appConfig) .catch((err) => console.error(err)); diff --git a/server/dist/index.js b/server/dist/index.js index 0849e77..b039f96 100644 --- a/server/dist/index.js +++ b/server/dist/index.js @@ -43,6 +43,9 @@ const approvePendingUserParamsSchema = z.object({ const adminDeleteUserParamsSchema = z.object({ userId: z.string().min(1), }); +const webBundleFileParamsSchema = z.object({ + '*': z.string().min(1), +}); const wsQuerySchema = z.object({ token: z.string().min(1), }); @@ -240,6 +243,45 @@ else { app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.'); } app.get('/api/health', async () => ({ ok: true })); +app.get('/api/web-app/manifest', async (request, reply) => { + const manifest = getFrontendBundleManifest(); + if (!manifest) { + return reply.code(404).send({ + message: 'Angular frontend build not found.', + frontendDistPath, + }); + } + const etag = `"${manifest.bundleId}"`; + reply.header('Cache-Control', 'no-cache'); + reply.header('ETag', etag); + if (requestMatchesEtag(request.headers['if-none-match'], etag)) { + return reply.code(304).send(); + } + return manifest; +}); +app.get('/api/web-app/files/*', async (request, reply) => { + const parsed = webBundleFileParamsSchema.safeParse(request.params); + if (!parsed.success) { + return reply.code(400).send({ + message: 'Invalid web bundle asset path.', + issues: parsed.error.flatten(), + }); + } + const asset = resolveFrontendBundleAsset(parsed.data['*']); + if (!asset) { + return reply.code(404).send({ message: 'Frontend asset not found.' }); + } + const etag = `W/"${asset.etag}"`; + reply.header('Cache-Control', 'public, max-age=300'); + reply.header('ETag', etag); + reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString()); + if (requestMatchesEtag(request.headers['if-none-match'], etag)) { + return reply.code(304).send(); + } + reply.header('Content-Length', String(asset.size)); + reply.type(asset.contentType); + return reply.send(fs.createReadStream(asset.absolutePath)); +}); app.post('/api/auth/register', async (request, reply) => { const parsed = registerSchema.safeParse(request.body); if (!parsed.success) { @@ -1123,6 +1165,129 @@ function inferImageMimeType(imageBuffer) { } return 'application/octet-stream'; } +function getFrontendBundleManifest() { + if (!fs.existsSync(frontendIndexPath)) { + return null; + } + const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => { + const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath)); + const stats = fs.statSync(absolutePath); + const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex'); + return { + path: relativePath, + size: stats.size, + sha256, + lastModified: stats.mtime.toISOString(), + contentType: detectBundleContentType(relativePath), + href: bundleAssetHref(relativePath), + }; + }); + files.sort((left, right) => left.path.localeCompare(right.path)); + const generatedAt = files.reduce((latest, file) => (file.lastModified > latest ? file.lastModified : latest), new Date(0).toISOString()); + const bundleId = files.reduce((hash, file) => { + hash.update(file.path); + hash.update(file.sha256); + hash.update(String(file.size)); + return hash; + }, crypto.createHash('sha256')).digest('hex'); + return { + bundleId, + generatedAt, + indexPath: 'index.html', + files, + }; +} +function resolveFrontendBundleAsset(relativeAssetPath) { + if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) { + return null; + } + const normalizedPath = toBundleRelativePath(relativeAssetPath); + if (normalizedPath.length === 0 || + normalizedPath === '.' || + normalizedPath.startsWith('../') || + normalizedPath.startsWith('/')) { + return null; + } + const absolutePath = path.resolve(frontendDistPath, normalizedPath); + const relativeToRoot = path.relative(frontendDistPath, absolutePath); + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) { + return null; + } + const stats = fs.statSync(absolutePath); + if (!stats.isFile()) { + return null; + } + return { + absolutePath, + contentType: detectBundleContentType(normalizedPath), + size: stats.size, + lastModifiedMs: stats.mtimeMs, + etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`, + }; +} +function listBundleFilePaths(rootPath) { + return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + return listBundleFilePaths(entryPath); + } + if (!entry.isFile()) { + return []; + } + return [entryPath]; + }); +} +function bundleAssetHref(relativePath) { + return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`; +} +function toBundleRelativePath(inputPath) { + return path.posix.normalize(inputPath.replaceAll('\\', '/')); +} +function detectBundleContentType(assetPath) { + const extension = path.extname(assetPath).toLowerCase(); + switch (extension) { + case '.css': + return 'text/css; charset=utf-8'; + case '.html': + return 'text/html; charset=utf-8'; + case '.ico': + return 'image/x-icon'; + case '.jpeg': + case '.jpg': + return 'image/jpeg'; + case '.js': + return 'text/javascript; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.map': + return 'application/json; charset=utf-8'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml; charset=utf-8'; + case '.txt': + return 'text/plain; charset=utf-8'; + case '.webp': + return 'image/webp'; + case '.webmanifest': + return 'application/manifest+json; charset=utf-8'; + case '.woff': + return 'font/woff'; + case '.woff2': + return 'font/woff2'; + default: + return 'application/octet-stream'; + } +} +function requestMatchesEtag(headerValue, etag) { + if (!headerValue) { + return false; + } + const incomingEtags = Array.isArray(headerValue) + ? headerValue + : headerValue.split(',').map((value) => value.trim()); + return incomingEtags.includes(etag) || incomingEtags.includes('*'); +} function normalizeSignal(signal) { if (signal.type === 'sdp') { return { diff --git a/server/src/index.ts b/server/src/index.ts index 02a60ff..177b8b6 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -171,6 +171,22 @@ type AccessKeySummary = { createdAt: string; }; +type WebBundleFileEntry = { + path: string; + size: number; + sha256: string; + lastModified: string; + contentType: string; + href: string; +}; + +type WebBundleManifest = { + bundleId: string; + generatedAt: string; + indexPath: string; + files: WebBundleFileEntry[]; +}; + type PendingRegistration = { challenge: string; label: string; @@ -233,6 +249,10 @@ const adminDeleteUserParamsSchema = z.object({ userId: z.string().min(1), }); +const webBundleFileParamsSchema = z.object({ + '*': z.string().min(1), +}); + const wsQuerySchema = z.object({ token: z.string().min(1), }); @@ -461,6 +481,57 @@ if (hasFrontendBuild) { app.get('/api/health', async () => ({ ok: true })); +app.get('/api/web-app/manifest', async (request, reply) => { + const manifest = getFrontendBundleManifest(); + + if (!manifest) { + return reply.code(404).send({ + message: 'Angular frontend build not found.', + frontendDistPath, + }); + } + + const etag = `"${manifest.bundleId}"`; + reply.header('Cache-Control', 'no-cache'); + reply.header('ETag', etag); + + if (requestMatchesEtag(request.headers['if-none-match'], etag)) { + return reply.code(304).send(); + } + + return manifest; +}); + +app.get('/api/web-app/files/*', async (request, reply) => { + const parsed = webBundleFileParamsSchema.safeParse(request.params); + + if (!parsed.success) { + return reply.code(400).send({ + message: 'Invalid web bundle asset path.', + issues: parsed.error.flatten(), + }); + } + + const asset = resolveFrontendBundleAsset(parsed.data['*']); + + if (!asset) { + return reply.code(404).send({ message: 'Frontend asset not found.' }); + } + + const etag = `W/"${asset.etag}"`; + reply.header('Cache-Control', 'public, max-age=300'); + reply.header('ETag', etag); + reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString()); + + if (requestMatchesEtag(request.headers['if-none-match'], etag)) { + return reply.code(304).send(); + } + + reply.header('Content-Length', String(asset.size)); + reply.type(asset.contentType); + return reply.send(fs.createReadStream(asset.absolutePath)); +}); + app.post('/api/auth/register', async (request, reply) => { const parsed = registerSchema.safeParse(request.body); @@ -1685,6 +1756,165 @@ function inferImageMimeType(imageBuffer: Buffer): string { return 'application/octet-stream'; } +function getFrontendBundleManifest(): WebBundleManifest | null { + if (!fs.existsSync(frontendIndexPath)) { + return null; + } + + const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => { + const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath)); + const stats = fs.statSync(absolutePath); + const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex'); + + return { + path: relativePath, + size: stats.size, + sha256, + lastModified: stats.mtime.toISOString(), + contentType: detectBundleContentType(relativePath), + href: bundleAssetHref(relativePath), + } satisfies WebBundleFileEntry; + }); + + files.sort((left, right) => left.path.localeCompare(right.path)); + + const generatedAt = files.reduce( + (latest, file) => (file.lastModified > latest ? file.lastModified : latest), + new Date(0).toISOString(), + ); + const bundleId = files.reduce((hash, file) => { + hash.update(file.path); + hash.update(file.sha256); + hash.update(String(file.size)); + return hash; + }, crypto.createHash('sha256')).digest('hex'); + + return { + bundleId, + generatedAt, + indexPath: 'index.html', + files, + }; +} + +function resolveFrontendBundleAsset(relativeAssetPath: string): { + absolutePath: string; + contentType: string; + size: number; + lastModifiedMs: number; + etag: string; +} | null { + if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) { + return null; + } + + const normalizedPath = toBundleRelativePath(relativeAssetPath); + + if ( + normalizedPath.length === 0 || + normalizedPath === '.' || + normalizedPath.startsWith('../') || + normalizedPath.startsWith('/') + ) { + return null; + } + + const absolutePath = path.resolve(frontendDistPath, normalizedPath); + const relativeToRoot = path.relative(frontendDistPath, absolutePath); + + if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) { + return null; + } + + const stats = fs.statSync(absolutePath); + + if (!stats.isFile()) { + return null; + } + + return { + absolutePath, + contentType: detectBundleContentType(normalizedPath), + size: stats.size, + lastModifiedMs: stats.mtimeMs, + etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`, + }; +} + +function listBundleFilePaths(rootPath: string): string[] { + return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(rootPath, entry.name); + + if (entry.isDirectory()) { + return listBundleFilePaths(entryPath); + } + + if (!entry.isFile()) { + return []; + } + + return [entryPath]; + }); +} + +function bundleAssetHref(relativePath: string): string { + return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`; +} + +function toBundleRelativePath(inputPath: string): string { + return path.posix.normalize(inputPath.replaceAll('\\', '/')); +} + +function detectBundleContentType(assetPath: string): string { + const extension = path.extname(assetPath).toLowerCase(); + + switch (extension) { + case '.css': + return 'text/css; charset=utf-8'; + case '.html': + return 'text/html; charset=utf-8'; + case '.ico': + return 'image/x-icon'; + case '.jpeg': + case '.jpg': + return 'image/jpeg'; + case '.js': + return 'text/javascript; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.map': + return 'application/json; charset=utf-8'; + case '.png': + return 'image/png'; + case '.svg': + return 'image/svg+xml; charset=utf-8'; + case '.txt': + return 'text/plain; charset=utf-8'; + case '.webp': + return 'image/webp'; + case '.webmanifest': + return 'application/manifest+json; charset=utf-8'; + case '.woff': + return 'font/woff'; + case '.woff2': + return 'font/woff2'; + default: + return 'application/octet-stream'; + } +} + +function requestMatchesEtag(headerValue: string | string[] | undefined, etag: string): boolean { + if (!headerValue) { + return false; + } + + const incomingEtags = Array.isArray(headerValue) + ? headerValue + : headerValue.split(',').map((value) => value.trim()); + + return incomingEtags.includes(etag) || incomingEtags.includes('*'); +} + function normalizeSignal(signal: Extract['signal']): SignalPayload { if (signal.type === 'sdp') { return {