feat: A1+A2 — Playwright smoke suite + index.html mtime-based caching
Safety net for upcoming refactors: - 26 Playwright smoke tests via @playwright/test 1.50.0 in an official Playwright container (no host Node needed). Covers every Bedside sub-pill, the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/ burns/airway), and interactive widgets (lightbox, vent SVG). - `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble on the ped-ai_default Docker network so no host port mapping is needed. - public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators component without the SPA auth wall (scripts external to satisfy CSP). Server: - server.js now re-reads public/index.html on mtime change instead of caching at boot. Fixes the "edit HTML, restart container" friction. - CSP upgradeInsecureRequests disabled in helmet config; Caddy still enforces HTTPS at the reverse-proxy layer in production.
This commit is contained in:
parent
441a872186
commit
1024b4741b
10 changed files with 354 additions and 12 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -30,3 +30,8 @@ android/.idea/
|
||||||
public/models/
|
public/models/
|
||||||
.env.backup-*
|
.env.backup-*
|
||||||
*.env.backup*
|
*.env.backup*
|
||||||
|
|
||||||
|
# e2e test artifacts (keep config + specs, skip results + installed deps)
|
||||||
|
e2e/node_modules/
|
||||||
|
e2e/test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
|
|
||||||
78
e2e/package-lock.json
generated
Normal file
78
e2e/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
{
|
||||||
|
"name": "ped-ai-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "ped-ai-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.50.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.50.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.0.tgz",
|
||||||
|
"integrity": "sha512-ZGNXbt+d65EGjBORQHuYKj+XhCewlwpnSd/EDuLPZGSiEWmgOJB5RmMCCYGy5aMfTs9wx61RivfDKi8H/hcMvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.50.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.50.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz",
|
||||||
|
"integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.50.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.50.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz",
|
||||||
|
"integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
e2e/package.json
Normal file
9
e2e/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "ped-ai-e2e",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "End-to-end smoke tests for PedScribe. Runs inside an official Playwright container; no host Node needed.",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.50.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
e2e/playwright.config.js
Normal file
24
e2e/playwright.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Playwright config — runs smoke tests against the already-running PedScribe
|
||||||
|
// container (no dev server spin-up). Expects BASE_URL (default
|
||||||
|
// http://host.docker.internal:3552 when run via scripts/e2e.sh).
|
||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.BASE_URL || 'http://host.docker.internal:3552',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
actionTimeout: 5_000,
|
||||||
|
navigationTimeout: 15_000,
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
147
e2e/tests/bedside-smoke.spec.js
Normal file
147
e2e/tests/bedside-smoke.spec.js
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// Bedside module smoke tests.
|
||||||
|
// Each test navigates to the Calculators tab, opens Bedside, clicks the target
|
||||||
|
// sub-pill, and asserts a known string is rendered. Catches "button doesn't
|
||||||
|
// fire / section goes blank" regressions.
|
||||||
|
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// Helper — open Calculators tab + Bedside panel + (optionally) a sub-pill
|
||||||
|
async function openCalculators(page) {
|
||||||
|
await page.goto('/e2e-harness.html');
|
||||||
|
await page.waitForFunction(() => window.__harnessReady === true);
|
||||||
|
// Give the async innerHTML injection + JS init one more microtask tick
|
||||||
|
await page.waitForSelector('button.calc-nav-pill[data-calc="bedside"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openBedside(page, subPill) {
|
||||||
|
await openCalculators(page);
|
||||||
|
await page.click('button.calc-nav-pill[data-calc="bedside"]');
|
||||||
|
if (subPill) {
|
||||||
|
await page.click(`button.calc-pill[data-em="${subPill}"]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Bedside — top-level', () => {
|
||||||
|
test('Calculators tab shows age-weight estimator', async ({ page }) => {
|
||||||
|
await openCalculators(page);
|
||||||
|
await expect(page.locator('#bedside-age')).toBeVisible();
|
||||||
|
await expect(page.locator('#bedside-formula')).toBeVisible();
|
||||||
|
await expect(page.locator('#bedside-weight')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Age → Weight: typing 3y auto-fills weight (APLS)', async ({ page }) => {
|
||||||
|
await openCalculators(page);
|
||||||
|
await page.fill('#bedside-age', '3y');
|
||||||
|
await expect(page.locator('#bedside-weight')).toHaveValue('14');
|
||||||
|
await expect(page.locator('#bedside-estimate-note')).toContainText('APLS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Formula switch to Best Guess updates weight', async ({ page }) => {
|
||||||
|
await openCalculators(page);
|
||||||
|
await page.fill('#bedside-age', '3y');
|
||||||
|
await page.selectOption('#bedside-formula', 'bestguess');
|
||||||
|
await expect(page.locator('#bedside-weight')).toHaveValue('16'); // 2 * (3+5) = 16
|
||||||
|
await expect(page.locator('#bedside-estimate-note')).toContainText('Best Guess');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear button resets the estimator', async ({ page }) => {
|
||||||
|
await openCalculators(page);
|
||||||
|
await page.fill('#bedside-age', '5y');
|
||||||
|
await page.click('#btn-bedside-clear');
|
||||||
|
await expect(page.locator('#bedside-age')).toHaveValue('');
|
||||||
|
await expect(page.locator('#bedside-weight')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Bedside — every sub-pill renders', () => {
|
||||||
|
const subPills = [
|
||||||
|
{ key: 'neonatal', expected: /Neonatal Assessment/i },
|
||||||
|
{ key: 'airway', expected: /Airway Management/i },
|
||||||
|
{ key: 'cardiac', expected: /Cardiac Arrest/i },
|
||||||
|
{ key: 'respiratory', expected: /Respiratory Management/i },
|
||||||
|
{ key: 'ventilation', expected: /Oxygen & Ventilation/i },
|
||||||
|
{ key: 'seizure', expected: /Status Epilepticus/i },
|
||||||
|
{ key: 'sepsis', expected: /Sepsis & Fever/i },
|
||||||
|
{ key: 'anaphylaxis', expected: /Anaphylaxis/i },
|
||||||
|
{ key: 'sedation', expected: /Procedural Sedation/i },
|
||||||
|
{ key: 'agitation', expected: /Acute Agitation/i },
|
||||||
|
{ key: 'antiemetics', expected: /Antiemetics/i },
|
||||||
|
{ key: 'antimicrobials', expected: /Empiric Antimicrobials/i },
|
||||||
|
{ key: 'burns', expected: /Burn Management/i },
|
||||||
|
{ key: 'toxicology', expected: /Toxicology/i },
|
||||||
|
{ key: 'trauma', expected: /Trauma/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { key, expected } of subPills) {
|
||||||
|
test(`${key} sub-pill shows header`, async ({ page }) => {
|
||||||
|
await openBedside(page, key);
|
||||||
|
await expect(page.locator(`#em-${key}`)).toContainText(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Bedside — dose calculators fire', () => {
|
||||||
|
test('Status Epilepticus: Show Pathway renders timeline with weight', async ({ page }) => {
|
||||||
|
await openBedside(page, 'seizure');
|
||||||
|
await page.fill('#seizure-weight', '20');
|
||||||
|
await page.click('#btn-seizure-calc');
|
||||||
|
await expect(page.locator('#seizure-result')).toContainText(/20 kg/);
|
||||||
|
await expect(page.locator('#seizure-result')).toContainText(/Lorazepam/);
|
||||||
|
await expect(page.locator('#seizure-result')).toContainText(/0\.1 mg\/kg/); // per-kg visible
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Sepsis: Show Approach renders Phoenix criteria + first-hour bundle', async ({ page }) => {
|
||||||
|
await openBedside(page, 'sepsis');
|
||||||
|
await page.fill('#sepsis-weight', '25');
|
||||||
|
await page.click('#btn-sepsis-show');
|
||||||
|
await expect(page.locator('#sepsis-result')).toContainText(/Phoenix/);
|
||||||
|
await expect(page.locator('#sepsis-result')).toContainText(/first-hour/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Anaphylaxis: Calculate Doses shows weight-based epinephrine', async ({ page }) => {
|
||||||
|
await openBedside(page, 'anaphylaxis');
|
||||||
|
await page.fill('#anaph-weight', '25');
|
||||||
|
await page.click('#btn-anaph-calc');
|
||||||
|
await expect(page.locator('#anaph-result')).toContainText(/Epinephrine/);
|
||||||
|
await expect(page.locator('#anaph-result')).toContainText(/0\.25 mg/); // 25*0.01
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Burns: body-parts calculator + Parkland', async ({ page }) => {
|
||||||
|
await openBedside(page, 'burns');
|
||||||
|
await page.fill('#burn-weight', '20');
|
||||||
|
await page.fill('input[data-burn-region="head"]', '50'); // 50% of 13 (young) = 6.5
|
||||||
|
await page.fill('input[data-burn-region="ant_trunk"]', '100'); // 100% of 13 = 13
|
||||||
|
await page.click('#btn-burn-calc');
|
||||||
|
await expect(page.locator('#burn-result')).toContainText(/Parkland/);
|
||||||
|
await expect(page.locator('#burn-result')).toContainText(/20 kg/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Airway: Calculate renders RSI drugs with per-kg', async ({ page }) => {
|
||||||
|
await openBedside(page, 'airway');
|
||||||
|
await page.fill('#airway-weight', '20');
|
||||||
|
await page.fill('#airway-age', '5');
|
||||||
|
await page.click('#btn-airway-calc');
|
||||||
|
await expect(page.locator('#airway-result')).toContainText(/Ketamine/);
|
||||||
|
await expect(page.locator('#airway-result')).toContainText(/mg\/kg/); // per-kg visible
|
||||||
|
await expect(page.locator('#airway-result')).toContainText(/ETT/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Bedside — interactive widgets', () => {
|
||||||
|
test('Lightbox: View pathway image button opens modal', async ({ page }) => {
|
||||||
|
await openBedside(page, 'seizure');
|
||||||
|
await page.click('button[data-img-src="/img/epilepsy_eiic_pathway.png"]');
|
||||||
|
await expect(page.locator('#img-lightbox')).toBeVisible();
|
||||||
|
await page.click('#img-lightbox-close');
|
||||||
|
await expect(page.locator('#img-lightbox')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ventilation: Show Reference renders pressure-time SVG', async ({ page }) => {
|
||||||
|
await openBedside(page, 'ventilation');
|
||||||
|
await page.fill('#vent-weight', '20');
|
||||||
|
await page.click('#btn-vent-show');
|
||||||
|
await expect(page.locator('#vent-result svg')).toBeVisible();
|
||||||
|
await expect(page.locator('#vent-result')).toContainText(/Target SpO2/);
|
||||||
|
await expect(page.locator('#vent-result')).toContainText(/PEEP/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"test": "node --test test/",
|
"test": "node --test test/",
|
||||||
|
"e2e": "./scripts/e2e.sh",
|
||||||
"maint:check": "node scripts/maintenance.js check",
|
"maint:check": "node scripts/maintenance.js check",
|
||||||
"maint:reindex": "node scripts/maintenance.js reindex",
|
"maint:reindex": "node scripts/maintenance.js reindex",
|
||||||
"migrate": "node-pg-migrate",
|
"migrate": "node-pg-migrate",
|
||||||
|
|
|
||||||
22
public/e2e-harness.html
Normal file
22
public/e2e-harness.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- E2E test harness — loads the calculators component + JS in isolation
|
||||||
|
(no auth wall). Playwright points here instead of the full SPA.
|
||||||
|
All scripts are external because the app's CSP blocks inline scripts. -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>E2E harness — calculators</title>
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 16px; background: #f9fafb; }
|
||||||
|
#calculators-tab { max-width: 1100px; margin: 0 auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="calculators-tab" class="tab-content active" data-component="calculators"></section>
|
||||||
|
|
||||||
|
<script defer src="/js/calc-math.js"></script>
|
||||||
|
<script defer src="/js/calculators.js"></script>
|
||||||
|
<script defer src="/js/e2e-bootstrap.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
public/js/e2e-bootstrap.js
Normal file
15
public/js/e2e-bootstrap.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// E2E harness bootstrap — external file because the app's CSP blocks inline scripts.
|
||||||
|
// Runs after calc-math.js + calculators.js (all three have `defer`, so execution
|
||||||
|
// is in document order once parsing completes).
|
||||||
|
(async function bootstrap() {
|
||||||
|
try {
|
||||||
|
const host = document.getElementById('calculators-tab');
|
||||||
|
const html = await fetch('/components/calculators.html').then(r => r.text());
|
||||||
|
host.innerHTML = html;
|
||||||
|
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: 'calculators' } }));
|
||||||
|
window.__harnessReady = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[e2e-harness] bootstrap failed:', e);
|
||||||
|
window.__harnessError = String(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
22
scripts/e2e.sh
Executable file
22
scripts/e2e.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs Playwright smoke tests inside the official Playwright container against
|
||||||
|
# the running PedScribe app. Usage: npm run e2e (or ./scripts/e2e.sh)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble"
|
||||||
|
|
||||||
|
# Attach the Playwright container to the same Docker network as the app so it
|
||||||
|
# can resolve pediatric-ai-scribe by service name. Internal container port is 3000.
|
||||||
|
NETWORK="${E2E_NETWORK:-ped-ai_default}"
|
||||||
|
BASE_URL="${BASE_URL:-http://pediatric-ai-scribe:3000}"
|
||||||
|
|
||||||
|
docker run --rm --ipc=host \
|
||||||
|
--network="$NETWORK" \
|
||||||
|
-v "$PWD/e2e":/work \
|
||||||
|
-w /work \
|
||||||
|
-e BASE_URL="$BASE_URL" \
|
||||||
|
-e CI=true \
|
||||||
|
"$IMAGE" \
|
||||||
|
sh -c "npm install --no-audit --no-fund --silent && npx playwright test"
|
||||||
43
server.js
43
server.js
|
|
@ -45,6 +45,10 @@ app.use(helmet({
|
||||||
childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'],
|
childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'],
|
||||||
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
|
frameSrc: ["'self'", 'https://challenges.cloudflare.com'],
|
||||||
objectSrc: ["'none'"],
|
objectSrc: ["'none'"],
|
||||||
|
// Disable helmet's default upgrade-insecure-requests so tests can hit
|
||||||
|
// the container over plain HTTP. In production, Caddy terminates TLS
|
||||||
|
// and serves HTTPS — mixed-content is a non-issue there.
|
||||||
|
upgradeInsecureRequests: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
@ -158,23 +162,38 @@ try {
|
||||||
}
|
}
|
||||||
console.log('🔖 Build ID:', BUILD_ID);
|
console.log('🔖 Build ID:', BUILD_ID);
|
||||||
|
|
||||||
// Template index.html once at boot with the version stamp. Serves fast
|
// Template index.html on each request if the file changed (mtime-based).
|
||||||
// because the rewrite happens one time and the string is reused.
|
// Avoids a stale in-memory copy after static edits / script additions while
|
||||||
var INDEX_HTML_TEMPLATED = null;
|
// still being essentially free — just an fs.stat per request, zero template
|
||||||
try {
|
// rebuild when mtime hasn't changed.
|
||||||
var raw = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
|
var INDEX_PATH = path.join(__dirname, 'public', 'index.html');
|
||||||
INDEX_HTML_TEMPLATED = raw.replace(
|
var _indexCached = { mtime: 0, html: null };
|
||||||
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g,
|
function getTemplatedIndex() {
|
||||||
'$1$2?v=' + BUILD_ID + '$3'
|
try {
|
||||||
);
|
var st = fs.statSync(INDEX_PATH);
|
||||||
} catch (e) { console.warn('[build-id] index.html template skipped:', e.message); }
|
if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html;
|
||||||
|
var raw = fs.readFileSync(INDEX_PATH, 'utf8');
|
||||||
|
_indexCached.html = raw.replace(
|
||||||
|
/(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g,
|
||||||
|
'$1$2?v=' + BUILD_ID + '$3'
|
||||||
|
);
|
||||||
|
_indexCached.mtime = st.mtimeMs;
|
||||||
|
return _indexCached.html;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[build-id] index.html read failed:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prime once at boot so first request is fast
|
||||||
|
getTemplatedIndex();
|
||||||
|
|
||||||
app.get(['/', '/index.html'], function(req, res) {
|
app.get(['/', '/index.html'], function(req, res) {
|
||||||
if (!INDEX_HTML_TEMPLATED) return res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
var html = getTemplatedIndex();
|
||||||
|
if (!html) return res.sendFile(INDEX_PATH);
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
res.setHeader('X-Build-Id', BUILD_ID);
|
res.setHeader('X-Build-Id', BUILD_ID);
|
||||||
res.send(INDEX_HTML_TEMPLATED);
|
res.send(html);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public endpoint for cache-bust debugging + build-info
|
// Public endpoint for cache-bust debugging + build-info
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue