File size: 4,031 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { extname, join, normalize, relative, resolve, sep } from 'node:path';
import type { FastifyReply, FastifyRequest } from 'fastify';
interface WebAssetRouteHost {
get(
path: string,
handler: (req: FastifyRequest, reply: FastifyReply) => Promise<unknown>,
): unknown;
}
export async function registerWebAssetRoutes(
app: WebAssetRouteHost,
assetsDir: string,
): Promise<void> {
await assertWebAssets(assetsDir);
app.get('/', async (req, reply) => serveWebAsset(req, reply, assetsDir));
app.get('/*', async (req, reply) => serveWebAsset(req, reply, assetsDir));
}
async function assertWebAssets(assetsDir: string): Promise<void> {
try {
const info = await stat(join(assetsDir, 'index.html'));
if (!info.isFile()) {
throw new Error('index.html is not a file');
}
} catch {
throw new Error(
`Kimi web assets were not found at ${assetsDir}. Run the package build before starting the server.`,
);
}
}
async function serveWebAsset(
req: FastifyRequest,
reply: FastifyReply,
assetsDir: string,
): Promise<unknown> {
const requestUrl = new URL(req.url, 'http://kimi-web.local');
if (isReservedPath(requestUrl.pathname)) {
return reply.callNotFound();
}
const filePath = await resolveStaticFile(assetsDir, requestUrl.pathname);
if (filePath === undefined) {
return reply.code(404).type('text/plain; charset=utf-8').send('Not found');
}
const fileInfo = await stat(filePath).catch(() => undefined);
if (fileInfo === undefined || !fileInfo.isFile()) {
return reply.code(404).type('text/plain; charset=utf-8').send('Not found');
}
return reply
.type(mimeType(filePath))
.header('Cache-Control', cacheControl(assetsDir, filePath))
.header('Content-Length', String(fileInfo.size))
.send(createReadStream(filePath));
}
function cacheControl(assetsDir: string, filePath: string): string {
const assetPath = relative(assetsDir, filePath);
const fileName = filePath.slice(filePath.lastIndexOf(sep) + 1);
if (assetPath.startsWith(`assets${sep}`) && /[-.][A-Za-z0-9_-]{8}\.[^.]+$/.test(fileName)) {
return 'public, max-age=31536000, immutable';
}
return 'no-cache';
}
async function resolveStaticFile(
assetsDir: string,
pathname: string,
): Promise<string | undefined> {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return undefined;
}
const normalized = normalize(decoded).replace(/^(\.\.(?:[/\\]|$))+/, '');
const relative = normalized === sep ? 'index.html' : normalized.replace(/^[/\\]/, '');
const root = resolve(assetsDir);
const candidate = resolve(
root,
relative.endsWith(sep) ? join(relative, 'index.html') : relative,
);
if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) {
return undefined;
}
const info = await stat(candidate).catch(() => undefined);
if (info?.isFile() === true) {
return candidate;
}
if (extname(pathname) !== '') {
return undefined;
}
return join(root, 'index.html');
}
function isReservedPath(pathname: string): boolean {
return (
pathname === '/api' ||
pathname.startsWith('/api/') ||
pathname === '/documentation' ||
pathname.startsWith('/documentation/')
);
}
function mimeType(filePath: string): string {
switch (extname(filePath)) {
case '.html':
return 'text/html; charset=utf-8';
case '.js':
case '.mjs':
return 'text/javascript; charset=utf-8';
case '.css':
return 'text/css; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.webp':
return 'image/webp';
case '.ico':
return 'image/x-icon';
case '.woff2':
return 'font/woff2';
default:
return 'application/octet-stream';
}
}
|