import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract, } from "./deployment-http-server.mjs"; const contract = loadDeploymentContract(); const root = fs.mkdtempSync(path.join(os.tmpdir(), "blender-deployment-http-")); function write(relative, bytes) { const filePath = path.join(root, relative); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, bytes); return Buffer.from(bytes); } function sha256(bytes) { return crypto.createHash("sha256").update(bytes).digest("hex"); } function deterministicBytes(size, seed) { const bytes = Buffer.alloc(size); for (let index = 0; index < bytes.length; index += 1) bytes[index] = (index * 31 + seed) & 0xff; return bytes; } const fixtures = new Map([ ["/index.html", write("index.html", "Web Blender")], ["/assets/app.js", write("assets/app.js", "export const app = true;\n")], ["/assets/app.css", write("assets/app.css", "body { color: black; }\n")], ["/vendor/blender/single/web_engine.js", write("vendor/blender/single/web_engine.js", "export default () => {};\n")], ["/vendor/blender/single/web_engine.wasm", write("vendor/blender/single/web_engine.wasm", deterministicBytes(1024, 7))], ["/vendor/blender/pthread/web_engine.js", write("vendor/blender/pthread/web_engine.js", "export default () => {}; // pthread\n")], ["/engine-manifest.json", write("engine-manifest.json", "{\"schemaVersion\":2}\n")], ["/media/frame.png", write("media/frame.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]))], ["/media/silence.wav", write("media/silence.wav", Buffer.from("RIFF0000WAVE"))], ["/fixtures/basic.blend", write("fixtures/basic.blend", deterministicBytes(4096, 11))], ["/volumes/smoke.nvdb", write("volumes/smoke.nvdb", deterministicBytes(1024 * 1024, 19))], ]); const server = createDeploymentHttpServer({ root, contract }); let origin; async function body(response) { return Buffer.from(await response.arrayBuffer()); } async function expectMime(url, expected, method = "GET") { const response = await fetch(`${origin}${url}`, { method }); const actual = response.headers.get("content-type"); assert.equal(actual, expected, `MIME mismatch url=${url} expected=${expected} actual=${actual}`); assert.equal(response.status, 200, `HTTP status mismatch url=${url} expected=200 actual=${response.status}`); return response; } function expectedMime(url) { const extension = path.extname(new URL(url, "http://deployment.local").pathname); const mime = contract.mimeTypes[extension]; assert.ok(mime, `deployment contract has no MIME for url=${url}`); return mime; } async function expectRange(url, header, expectedStart, expectedEnd) { const source = fixtures.get(url); const response = await fetch(`${origin}${url}`, { headers: { Range: header } }); assert.equal(response.status, contract.rangeRequests.satisfiedStatus, `range status url=${url} range=${header}`); const bytes = await body(response); assert.deepEqual(bytes, source.subarray(expectedStart, expectedEnd + 1), `range bytes url=${url} range=${header}`); for (const name of contract.rangeRequests.requiredSatisfiedHeaders) { assert.ok(response.headers.has(name), `missing ${name} url=${url} range=${header}`); } assert.equal(response.headers.get("content-range"), `bytes ${expectedStart}-${expectedEnd}/${source.length}`); assert.equal(Number(response.headers.get("content-length")), expectedEnd - expectedStart + 1); assert.match(response.headers.get("etag"), /^"sha256-[a-f0-9]{64}"$/); return response; } async function interruptedPrefix(url) { return new Promise((resolve, reject) => { const chunks = []; const request = http.get(`${origin}${url}`, (response) => { response.once("data", (chunk) => { chunks.push(chunk); response.destroy(); resolve({ bytes: Buffer.concat(chunks), etag: response.headers.etag }); }); response.on("error", (error) => { if (error.code !== "ECONNRESET") reject(error); }); }); request.on("error", reject); }); } try { origin = await listenDeploymentHttpServer(server); const mimeCases = [ ["/index.html", ".html"], ["/assets/app.js", ".js"], ["/assets/app.css", ".css"], ["/vendor/blender/single/web_engine.wasm", ".wasm"], ["/engine-manifest.json", ".json"], ["/media/frame.png", ".png"], ["/media/silence.wav", ".wav"], ["/fixtures/basic.blend", ".blend"], ["/volumes/smoke.nvdb", ".nvdb"], ]; for (const [url, extension] of mimeCases) { assert.equal(expectedMime(url), contract.mimeTypes[extension]); const response = await expectMime(url, contract.mimeTypes[extension]); assert.deepEqual(await body(response), fixtures.get(url)); } for (const url of [ "/vendor/blender/single/web_engine.js", "/vendor/blender/single/web_engine.wasm", "/vendor/blender/pthread/web_engine.js", "/fixtures/basic.blend", ]) { const response = await expectMime(url, expectedMime(url), "HEAD"); assert.equal((await response.arrayBuffer()).byteLength, 0, `HEAD returned a body url=${url}`); assert.equal(Number(response.headers.get("content-length")), fixtures.get(url).length); } await expectRange("/vendor/blender/single/web_engine.wasm", "bytes=100-199", 100, 199); await expectRange("/fixtures/basic.blend", "bytes=200-399", 200, 399); await expectRange("/volumes/smoke.nvdb", "bytes=300-599", 300, 599); await expectRange("/fixtures/basic.blend", "bytes=400-", 400, fixtures.get("/fixtures/basic.blend").length - 1); await expectRange("/fixtures/basic.blend", "bytes=-128", fixtures.get("/fixtures/basic.blend").length - 128, fixtures.get("/fixtures/basic.blend").length - 1); for (const range of ["items=0-1", "bytes=", "bytes=20-10", "bytes=0-999999", "bytes=0-1,4-5", "bytes=-0"]) { const response = await fetch(`${origin}/fixtures/basic.blend`, { headers: { Range: range } }); assert.equal(response.status, contract.rangeRequests.unsatisfiedStatus, `invalid range accepted range=${range}`); assert.equal((await response.arrayBuffer()).byteLength, 0); assert.equal(response.headers.get("content-range"), `bytes */${fixtures.get("/fixtures/basic.blend").length}`); } const etagFirst = (await fetch(`${origin}/fixtures/basic.blend`)).headers.get("etag"); const etagSecond = (await fetch(`${origin}/fixtures/basic.blend`)).headers.get("etag"); assert.equal(etagFirst, etagSecond); const matchingIfRange = await fetch(`${origin}/fixtures/basic.blend`, { headers: { Range: "bytes=0-31", "If-Range": etagFirst }, }); assert.equal(matchingIfRange.status, 206); const mismatchingIfRange = await fetch(`${origin}/fixtures/basic.blend`, { headers: { Range: "bytes=0-31", "If-Range": '"sha256-old-revision"' }, }); assert.equal(mismatchingIfRange.status, 200); assert.deepEqual(await body(mismatchingIfRange), fixtures.get("/fixtures/basic.blend")); const interrupted = await interruptedPrefix("/volumes/smoke.nvdb"); assert.ok(interrupted.bytes.length > 0 && interrupted.bytes.length < fixtures.get("/volumes/smoke.nvdb").length); const resumed = await fetch(`${origin}/volumes/smoke.nvdb`, { headers: { Range: `bytes=${interrupted.bytes.length}-`, "If-Range": interrupted.etag, }, }); assert.equal(resumed.status, 206); const merged = Buffer.concat([interrupted.bytes, await body(resumed)]); assert.equal(sha256(merged), sha256(fixtures.get("/volumes/smoke.nvdb"))); const replacement = deterministicBytes(fixtures.get("/fixtures/basic.blend").length, 23); fs.writeFileSync(path.join(root, "fixtures/basic.blend"), replacement); const changed = await fetch(`${origin}/fixtures/basic.blend`, { headers: { Range: "bytes=32-", "If-Range": etagFirst }, }); assert.equal(changed.status, 200); assert.notEqual(changed.headers.get("etag"), etagFirst); assert.deepEqual(await body(changed), replacement); process.stdout.write("deployment-http-ok mime=9 head=4 ranges=5 invalidRanges=6 resumeSha256=verified\n"); } finally { if (server.listening) await new Promise((resolve) => server.close(resolve)); fs.rmSync(root, { recursive: true, force: true }); }