← Files DataARCHIVED FILE

scripts/data-app-separate.mjs

12.7 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import { createHash } from "node:crypto";
import { closeSync, createReadStream, createWriteStream, lstatSync, openSync, readFileSync, readSync, realpathSync } from "node:fs";
import { mkdir, mkdtemp, rename, rm } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { pipeline } from "node:stream/promises";

import { inspectDataAppDocument } from "../skills/publish-artifact-to-sites/scripts/publication-assets.mjs";

export const SEPARATE_DATA_KIND = "separate-data-v1";
export const SEPARATE_BUILD_MANIFEST = "data-app-build.json";
export const PRESERVED_SEPARATE_DIRECTORY = ".data-app-offline/separate-v1";
const HASH = /^[a-f\d]{64}$/u;
const CHUNK_BYTES = 64 * 1024;
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");

function fail() { throw new Error("The separate Data app bundle is missing, invalid, or changed. Rebuild it before continuing."); }
function parseJson(bytes) { try { return JSON.parse(bytes); } catch { fail(); } }
function decodeHtml(bytes) { try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { fail(); } }
function pathExists(path) {
  try { lstatSync(path); return true; }
  catch (error) { if (error.code === "ENOENT") return false; throw error; }
}

function containedPath(root, name, { allowMissing = false } = {}) {
  if (typeof name !== "string" || !name || isAbsolute(name) || /[\\:\u0000-\u001f\u007f]/u.test(name)
    || name.split("/").some(part => !part || part === "." || part === "..")) fail();
  let path = root;
  const parts = name.split("/");
  for (let index = 0; index < parts.length; index++) {
    path = join(path, parts[index]);
    let entry;
    try { entry = lstatSync(path); }
    catch (error) { if (allowMissing && error.code === "ENOENT") continue; throw error; }
    if (entry.isSymbolicLink() || (index === parts.length - 1 ? !entry.isFile() : !entry.isDirectory())) fail();
  }
  return path;
}

// Hash large files without retaining another file-sized Buffer. The source and
// bundle readers still own their ordinary input graph; this bounds this pass.
export function fingerprintDataFile(path) {
  const hash = createHash("sha256"), chunk = Buffer.allocUnsafe(CHUNK_BYTES);
  const file = openSync(path, "r");
  let bytes = 0;
  try {
    for (;;) {
      const length = readSync(file, chunk, 0, chunk.length, null);
      if (!length) break;
      bytes += length;
      hash.update(chunk.subarray(0, length));
    }
  } finally { closeSync(file); }
  return { sha256: hash.digest("hex"), bytes };
}

function keys(value, expected) {
  return value && typeof value === "object" && !Array.isArray(value)
    && Object.keys(value).sort().join(",") === [...expected].sort().join(",");
}

export function validateSeparateDataManifest(manifest) {
  if (!keys(manifest, ["version", "kind", "html", "snapshot", "runtimeSha256", "sourceSnapshotSha256"])
    || manifest.version !== 1 || manifest.kind !== SEPARATE_DATA_KIND
    || !HASH.test(manifest.runtimeSha256 ?? "") || !HASH.test(manifest.sourceSnapshotSha256 ?? "")) fail();
  for (const descriptor of [manifest.html, manifest.snapshot]) {
    if (!keys(descriptor, ["path", "sha256", "bytes"]) || !HASH.test(descriptor.sha256 ?? "")
      || !Number.isSafeInteger(descriptor.bytes) || descriptor.bytes <= 0) fail();
  }
  if (manifest.html.path !== "index.html" || manifest.snapshot.sha256 !== manifest.sourceSnapshotSha256
    || manifest.snapshot.path !== `snapshot.${manifest.snapshot.sha256}.json`) fail();
  return manifest;
}

// Share the publisher's HTML tokenizer: raw CSS/JS and inert template content
// cannot impersonate live bootstrap markers or the reviewed snapshot element.
function inspectSeparateHtml(html) {
  const inspection = inspectDataAppDocument(html), metadata = new Map();
  for (const marker of inspection.metadata) {
    if (!marker.names.some(name => name.startsWith("data-app-"))) continue;
    if (marker.names.length !== 1 || marker.contents.length !== 1 || metadata.has(marker.names[0])) fail();
    metadata.set(marker.names[0], { ...marker, value: marker.contents[0] });
  }
  if (!inspection.snapshotElement || !inspection.headOpeningEnd) fail();
  return { metadata, snapshotElement: inspection.snapshotElement };
}

export function readSeparateDataBundle({ projectDir, manifestPath = `dist/${SEPARATE_BUILD_MANIFEST}` }) {
  const projectRoot = realpathSync(projectDir);
  const projection = assertHydratedPublicationSource(projectRoot);
  if (isAbsolute(manifestPath)) manifestPath = relative(projectRoot, resolve(manifestPath)).split(sep).join("/");
  const absoluteManifest = containedPath(projectRoot, manifestPath);
  if (lstatSync(absoluteManifest).size > 65536) fail();
  const manifest = validateSeparateDataManifest(parseJson(readFileSync(absoluteManifest, "utf8")));
  if (projection && (manifest.snapshot.sha256 !== projection.sha256 || manifest.snapshot.bytes !== projection.bytes)) fail();
  const directory = dirname(manifestPath);
  const htmlPath = containedPath(projectRoot, `${directory}/${manifest.html.path}`);
  const snapshotPath = containedPath(projectRoot, `${directory}/${manifest.snapshot.path}`);
  const htmlBytes = readFileSync(htmlPath);
  if (htmlBytes.length !== manifest.html.bytes || digest(htmlBytes) !== manifest.html.sha256) fail();
  const snapshotFingerprint = fingerprintDataFile(snapshotPath);
  if (snapshotFingerprint.sha256 !== manifest.snapshot.sha256 || snapshotFingerprint.bytes !== manifest.snapshot.bytes) fail();
  const html = decodeHtml(htmlBytes);
  const inspection = inspectSeparateHtml(html);
  for (const [name, expected] of Object.entries({
    "data-app-build-layout": SEPARATE_DATA_KIND,
    "data-app-local-snapshot": manifest.snapshot.path,
    "data-app-snapshot-sha256": manifest.snapshot.sha256,
    "data-app-runtime-sha256": manifest.runtimeSha256,
    "data-app-bootstrap": "deferred-content-v1",
  })) if (inspection.metadata.get(name)?.value !== expected) fail();
  if (inspection.metadata.has("data-app-snapshot-storage")) fail();
  const embedded = parseJson(html.slice(inspection.snapshotElement.contentStart, inspection.snapshotElement.contentEnd));
  if (!embedded || typeof embedded !== "object" || Array.isArray(embedded) || !keys(embedded.queries, [])
    || Object.keys(embedded).some(key => !["id", "title", "surface", "queries"].includes(key))) fail();
  return { manifest, manifestPath: absoluteManifest, htmlPath, htmlBytes, snapshotPath };
}

export function assertHydratedPublicationSource(projectDir) {
  const marker = ".openai/data-app-publication-source.json";
  if (!pathExists(join(projectDir, marker))) return;
  try {
    const root = realpathSync(projectDir), path = containedPath(root, marker);
    if (lstatSync(path).size > 4 * 1024 * 1024) fail();
    const manifest = parseJson(readFileSync(path, "utf8"));
    if (manifest.version !== 1 || manifest.kind !== "data-app-publication-source-v1"
      || !HASH.test(manifest.sourceSnapshotSha256 ?? "") || manifest.snapshot?.sha256 !== manifest.sourceSnapshotSha256
      || !Number.isSafeInteger(manifest.snapshot.bytes) || manifest.snapshot.bytes <= 0) fail();
    const actual = fingerprintDataFile(containedPath(root, "src/data.json"));
    if (actual.sha256 !== manifest.snapshot.sha256 || actual.bytes !== manifest.snapshot.bytes) fail();
    return actual;
  } catch {
    throw new Error("This Data publication source requires its exact verified snapshot hydration before building or exporting offline. Use the authoring project for data changes.");
  }
}

export function resolveSeparateDataBundle({ projectDir }) {
  assertHydratedPublicationSource(projectDir);
  const projectRoot = realpathSync(projectDir);
  if (pathExists(join(projectRoot, "dist", SEPARATE_BUILD_MANIFEST))) return readSeparateDataBundle({ projectDir });
  const path = containedPath(projectRoot, ".data-app-assets/manifest.json");
  if (lstatSync(path).size > 65536) fail();
  const publication = parseJson(readFileSync(path, "utf8"));
  if (publication.version !== 1 || publication.source?.layout !== SEPARATE_DATA_KIND
    || publication.source.buildManifestPath !== `${PRESERVED_SEPARATE_DIRECTORY}/${SEPARATE_BUILD_MANIFEST}`) fail();
  const hostedHtml = containedPath(projectRoot, "dist/index.html");
  const current = fingerprintDataFile(hostedHtml);
  if (current.sha256 !== publication.assets?.html?.sha256 || current.bytes !== publication.assets?.html?.bytes) fail();
  const bundle = readSeparateDataBundle({ projectDir, manifestPath: publication.source.buildManifestPath });
  if (bundle.manifest.html.sha256 !== publication.source.htmlSha256
    || bundle.manifest.snapshot.sha256 !== publication.source.snapshotSha256
    || bundle.manifest.snapshot.sha256 !== publication.assets?.snapshot?.sha256
    || bundle.manifest.snapshot.bytes !== publication.assets?.snapshot?.bytes) fail();
  return bundle;
}

function supportsSnapshotChunks(html) {
  // Inspect only the final canonical bootstrap, not strings in authored code
  // or exports in the runtime bundle. Older compiled pages keep their original
  // single-element export without requiring a client upgrade.
  const start = html.lastIndexOf("<script"), end = html.indexOf("</script>", start);
  if (start < 0 || end < 0) return false;
  const script = html.slice(start, end + 9);
  const encoded = /^<script src="data:text\/javascript;charset=utf-8;base64,([A-Za-z\d+/=]+)"><\/script>$/u.exec(script);
  const source = encoded ? Buffer.from(encoded[1], "base64").toString("utf8") : script;
  return source.includes("const continuation = document.querySelectorAll('script[data-app-snapshot-chunk]');\nconst reviewedSnapshot = continuation.length ? (() => { const parser = runtime.createStreamingJsonParser();");
}

export async function exportOfflineDataApp({ projectDir, outputPath = ".data-app-offline/exports/data-app-offline.html" }) {
  const projectRoot = realpathSync(projectDir);
  const bundle = resolveSeparateDataBundle({ projectDir });
  const outputRelative = relative(projectRoot, resolve(projectRoot, outputPath)).split(sep).join("/");
  if (!outputRelative.endsWith(".html") || !outputRelative.startsWith(".data-app-offline/exports/")) {
    throw new Error("Choose an offline .html output inside the project’s .data-app-offline/exports/ directory.");
  }
  const destination = containedPath(projectRoot, outputRelative, { allowMissing: true });
  const html = decodeHtml(bundle.htmlBytes);
  const snapshotChunks = supportsSnapshotChunks(html);
  const { metadata, snapshotElement } = inspectSeparateHtml(html);
  let prefix = html.slice(0, snapshotElement.contentStart);
  for (const marker of [metadata.get("data-app-build-layout"), metadata.get("data-app-local-snapshot")].sort((a, b) => b.start - a.start)) {
    prefix = prefix.slice(0, marker.start) + prefix.slice(marker.end);
  }
  const suffix = html.slice(snapshotElement.contentEnd);
  await mkdir(dirname(destination), { recursive: true });
  const scratch = await mkdtemp(join(dirname(destination), ".data-app-export-"));
  const candidate = join(scratch, "index.html");
  const outputHash = createHash("sha256");
  let outputBytes = 0;
  async function* chunks() {
    yield prefix;
    const hash = createHash("sha256"), decoder = new TextDecoder("utf-8", { fatal: true });
    let bytes = 0;
    const escape = text => text.replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
    let first = true;
    for await (const chunk of createReadStream(bundle.snapshotPath, { highWaterMark: CHUNK_BYTES })) {
      bytes += chunk.length; hash.update(chunk);
      if (bytes > bundle.manifest.snapshot.bytes) fail();
      // Separate inert elements keep the browser from creating one enormous
      // DOM string before the shared incremental parser can consume it.
      if (!first && snapshotChunks) yield '</script><script type="application/json" data-app-snapshot-chunk>';
      first = false;
      yield escape(decoder.decode(chunk, { stream: true }));
    }
    yield escape(decoder.decode());
    if (bytes !== bundle.manifest.snapshot.bytes || hash.digest("hex") !== bundle.manifest.snapshot.sha256) fail();
    yield suffix;
  }
  async function* measure(source) {
    for await (const chunk of source) {
      const bytes = Buffer.from(chunk);
      outputBytes += bytes.length; outputHash.update(bytes); yield bytes;
    }
  }
  try {
    await pipeline(chunks(), measure, createWriteStream(candidate, { flags: "wx" }));
    // Publication replacement happens only after the entire verified input was
    // streamed successfully. A failed read leaves the previous export intact.
    await rename(candidate, destination);
  } finally { await rm(scratch, { recursive: true, force: true }); }
  return { projectRoot, htmlPath: destination, htmlSha256: outputHash.digest("hex"), htmlBytes: outputBytes,
    snapshotSha256: bundle.manifest.snapshot.sha256, runtimeSha256: bundle.manifest.runtimeSha256, offline: true };
}

SHA-256: 26863a4e45775820bfedc5c99853dcf3696133a0e881c5398618e74fd902fac5