UNPKG

@himorishige/noren-dict-reloader

Version:

Dynamic dictionary and policy hot-reloading for Noren using ETag-based updates

88 lines (87 loc) 3.39 kB
import { createHash } from 'node:crypto'; import { readFile, realpath, stat } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defaultLoader } from './hotreload.js'; function sha256Hex(s) { const h = createHash('sha256'); h.update(s); return h.digest('hex'); } function toLastModifiedString(ms) { return new Date(ms).toUTCString(); } /** * Create a LoaderFn that supports file:// URLs in Node.js. * Non-file schemes are delegated to the provided fallback (default: HTTP loader). */ export function createFileLoader(fallback = defaultLoader, opts) { return async (url, prev, headers, force) => { if (!url.startsWith('file://')) { return fallback(url, prev, headers, force); } let parsedUrl; try { parsedUrl = new URL(url); } catch { throw new Error(`Invalid file URL: ${url}`); } // Validate URL format and security constraints if (parsedUrl.search || parsedUrl.hash) { throw new Error(`Invalid file URL (query/hash not allowed): ${url}`); } if (parsedUrl.hostname && parsedUrl.hostname !== 'localhost' && !opts?.allowRemoteFileHosts?.includes(parsedUrl.hostname)) { throw new Error(`Remote file host not allowed: ${parsedUrl.hostname}`); } let text; let etag; let lastModified; try { const p = fileURLToPath(parsedUrl); // Resolve symlinks and normalize to mitigate path traversal via symlinks const real = await realpath(p); // Security: Ensure path is within baseDir if specified if (opts?.baseDir) { const base = resolve(opts.baseDir); const normalizedBase = base.endsWith(sep) ? base : base + sep; const normalizedReal = real.endsWith(sep) ? real : real + sep; if (!normalizedReal.startsWith(normalizedBase)) { throw new Error(`Access outside of baseDir is not allowed: ${real}`); } } const st = await stat(real); // Security: Validate file type and size if (!st.isFile()) { throw new Error(`Access to non-regular file denied: ${real}`); } if (opts?.maxBytes && st.size > opts.maxBytes) { throw new Error(`File size ${st.size} exceeds limit ${opts.maxBytes}: ${real}`); } text = await readFile(real, 'utf8'); etag = `W/"sha256:${sha256Hex(text)}"`; lastModified = toLastModifiedString(st.mtimeMs); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`File access failed for ${url}: ${msg}`); } if (!force && prev && (prev.etag === etag || prev.lastModified === lastModified)) { return { status: 304, meta: prev }; } let json; try { json = JSON.parse(text); } catch { json = text; } return { status: 200, meta: { etag, lastModified, text, json } }; }; } /** * A ready-to-use loader that handles file:// and falls back to HTTP(S). */ export const fileLoader = createFileLoader();