Fable clean up
This commit is contained in:
parent
3441a7e4af
commit
4ce8a4f41d
46 changed files with 642 additions and 911 deletions
89
scripts/lib/browser.mjs
Normal file
89
scripts/lib/browser.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
export const CLOSE_TIMEOUT_MS = 3000;
|
||||
export const LAUNCH_TIMEOUT_MS = 10000;
|
||||
export const CONTEXT_TIMEOUT_MS = 8000;
|
||||
export const PAGE_TIMEOUT_MS = 15000;
|
||||
|
||||
// Chromium puts profiles and internal files in the process temp directory.
|
||||
// Some CI/dev containers mount /tmp as a very small tmpfs, so the checks point
|
||||
// it at an already-ignored directory under .astro/ instead.
|
||||
export async function setupBrowserTmp(browserTmp) {
|
||||
await rm(browserTmp, { recursive: true, force: true });
|
||||
await mkdir(browserTmp, { recursive: true });
|
||||
process.env.TMPDIR = browserTmp;
|
||||
process.env.TMP = browserTmp;
|
||||
process.env.TEMP = browserTmp;
|
||||
}
|
||||
|
||||
export async function cleanupBrowserTmp(browserTmp) {
|
||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function safeClosePage(page) {
|
||||
await withTimeout(
|
||||
page.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright page'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
export async function safeCloseContext(context) {
|
||||
await withTimeout(
|
||||
context.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Playwright context'
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
export async function safeCloseBrowser(browser) {
|
||||
const childProcess = browser.process?.();
|
||||
try {
|
||||
await withTimeout(
|
||||
browser.close(),
|
||||
CLOSE_TIMEOUT_MS,
|
||||
'Timed out while closing Chromium'
|
||||
);
|
||||
} catch {
|
||||
childProcess?.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
export function openBrowser(browserTmp) {
|
||||
return withTimeout(
|
||||
chromium.launch({
|
||||
headless: true,
|
||||
env: {
|
||||
...process.env,
|
||||
TMPDIR: browserTmp,
|
||||
TMP: browserTmp,
|
||||
TEMP: browserTmp,
|
||||
},
|
||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||
}),
|
||||
LAUNCH_TIMEOUT_MS,
|
||||
'Timed out while launching Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRetryNavigation(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||
message
|
||||
);
|
||||
}
|
||||
108
scripts/lib/dist.mjs
Normal file
108
scripts/lib/dist.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { walk } from './walk.mjs';
|
||||
|
||||
export const dist = path.resolve('dist');
|
||||
|
||||
const INDEX_FILE = 'index.html';
|
||||
|
||||
export async function requireDist() {
|
||||
try {
|
||||
await stat(dist);
|
||||
} catch {
|
||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||
}
|
||||
}
|
||||
|
||||
// Derives the site's route list from the HTML files in dist/.
|
||||
export async function discoverRoutes() {
|
||||
const files = await walk(dist);
|
||||
const routes = new Set();
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.html')) continue;
|
||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||
if (rel === '404.html') continue;
|
||||
if (rel === INDEX_FILE) {
|
||||
routes.add('/');
|
||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||
} else {
|
||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
return [...routes].sort();
|
||||
}
|
||||
|
||||
// Maps a request URL (or pathname) to a file in dist/, mirroring static-host
|
||||
// semantics: the path itself, its directory index.html, or the extensionless
|
||||
// .html variant. Returns null when nothing matches.
|
||||
export async function resolveFile(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const safePath = path
|
||||
.normalize(decodeURIComponent(parsed.pathname))
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const candidate = path.join(dist, safePath);
|
||||
const candidates = [
|
||||
candidate,
|
||||
path.join(candidate, 'index.html'),
|
||||
path.join(dist, `${safePath}.html`),
|
||||
];
|
||||
|
||||
for (const file of candidates) {
|
||||
try {
|
||||
const fileStat = await stat(file);
|
||||
if (fileStat.isFile()) return file;
|
||||
} catch {
|
||||
// Try the next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.vtt': 'text/vtt; charset=utf-8',
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export function contentType(file) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
return MIME[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
// Serves dist/ on an ephemeral localhost port, falling back to 404.html.
|
||||
// Returns the server (callers own closing it) and the assigned port.
|
||||
export async function startDistServer() {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const file = (await resolveFile(req.url ?? '/')) ?? path.join(dist, '404.html');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': contentType(file) });
|
||||
res.end(body);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, port: server.address().port };
|
||||
}
|
||||
23
scripts/lib/walk.mjs
Normal file
23
scripts/lib/walk.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Recursively lists every file under entryPath. A path that is itself a file
|
||||
// resolves to a single-element list, so callers can mix files and directories.
|
||||
export async function walk(entryPath) {
|
||||
const entryStat = await stat(entryPath);
|
||||
if (entryStat.isFile()) return [entryPath];
|
||||
|
||||
const entries = await readdir(entryPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(entryPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(fullPath)));
|
||||
} else {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue