PWA
This commit is contained in:
165
server/dist/index.js
vendored
165
server/dist/index.js
vendored
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user