From 1fcf2d17a77673eb724e0a92149f0082adac5b3d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 22:51:30 -0600 Subject: [PATCH 1/5] Publish catalog as generated content --- .../workflows/chart-library-benchmarks.yml | 63 ++- .gitignore | 4 +- API-FRICTION.md | 105 ++-- benchmarks/conformance/README.md | 106 ++-- deploy/catalog/_headers | 16 - examples/conformance/package.json | 4 +- examples/conformance/vite.config.ts | 1 + package.json | 8 +- scripts/build-conformance-artifact.mjs | 160 ++++++ scripts/build-conformance-site.mjs | 311 ----------- scripts/catalog-artifact.mjs | 520 ++++++++++++++++++ scripts/catalog-artifact.test.mjs | 201 +++++++ scripts/catalog-deployment.test.mjs | 80 --- scripts/check-catalog-deployment.mjs | 191 ------- scripts/check-catalog-loading.mjs | 274 ++------- scripts/check-conformance-artifact.mjs | 111 ++++ scripts/check-conformance-site.mjs | 132 ----- scripts/check-local-catalog-worker.mjs | 30 - scripts/stage-conformance-deployment.mjs | 165 ------ wrangler.catalog.jsonc | 19 - 20 files changed, 1188 insertions(+), 1313 deletions(-) delete mode 100644 deploy/catalog/_headers create mode 100644 scripts/build-conformance-artifact.mjs delete mode 100644 scripts/build-conformance-site.mjs create mode 100644 scripts/catalog-artifact.mjs create mode 100644 scripts/catalog-artifact.test.mjs delete mode 100644 scripts/catalog-deployment.test.mjs delete mode 100644 scripts/check-catalog-deployment.mjs create mode 100644 scripts/check-conformance-artifact.mjs delete mode 100644 scripts/check-conformance-site.mjs delete mode 100644 scripts/check-local-catalog-worker.mjs delete mode 100644 scripts/stage-conformance-deployment.mjs delete mode 100644 wrangler.catalog.jsonc diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index 2dcd2d92..e40b8517 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -40,7 +40,6 @@ jobs: - run: pnpm benchmark:check - run: pnpm catalog:check - run: pnpm catalog:build - - run: pnpm catalog:deploy:check - run: git diff --check - name: Upload production catalog @@ -48,8 +47,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: charts-catalog-${{ github.sha }} - path: examples/conformance/dist + path: .catalog-artifact if-no-files-found: error + include-hidden-files: true compare: needs: validate @@ -153,46 +153,49 @@ jobs: path: .benchmark-output/stress/results if-no-files-found: error - deploy-catalog: + publish-catalog: if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: - validate - conformance runs-on: ubuntu-latest timeout-minutes: 10 - environment: - name: charts-catalog-production - url: https://tanstack.com/charts/catalog/ + permissions: + contents: write steps: - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 with: - node-version: 24 - - - run: corepack enable - - run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps chromium + fetch-depth: 0 - name: Download validated catalog uses: actions/download-artifact@v4 with: name: charts-catalog-${{ github.sha }} - path: examples/conformance/dist - - - name: Stage catalog below its public path - run: node scripts/stage-conformance-deployment.mjs - - - name: Deploy catalog Worker - uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3.15.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - command: deploy --config wrangler.catalog.jsonc - wranglerVersion: 4.103.0 - - - name: Verify production routes - run: node scripts/check-catalog-deployment.mjs - - - name: Verify production loading boundary - run: node scripts/check-catalog-loading.mjs --url https://tanstack.com/charts/catalog/ + path: .catalog-artifact + + - name: Publish generated catalog + env: + CATALOG_BRANCH: catalog-dist + run: | + publication_dir="$(mktemp -d)" + if git ls-remote --exit-code --heads origin "$CATALOG_BRANCH" >/dev/null 2>&1; then + git fetch origin "$CATALOG_BRANCH" + git worktree add -B "$CATALOG_BRANCH" "$publication_dir" "origin/$CATALOG_BRANCH" + else + git worktree add --detach "$publication_dir" + git -C "$publication_dir" switch --orphan "$CATALOG_BRANCH" + fi + + git -C "$publication_dir" rm -rf --ignore-unmatch . + cp -R "$GITHUB_WORKSPACE/.catalog-artifact/." "$publication_dir/" + git -C "$publication_dir" add --all + + if git -C "$publication_dir" diff --cached --quiet; then + exit 0 + fi + + git -C "$publication_dir" config user.name "github-actions[bot]" + git -C "$publication_dir" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "$publication_dir" commit -m "Publish catalog $GITHUB_SHA" + git -C "$publication_dir" push origin "HEAD:$CATALOG_BRANCH" diff --git a/.gitignore b/.gitignore index 519aba4e..e681f55a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,7 @@ node_modules dist .bundle-output .benchmark-output -.catalog-deploy -.catalog-worker -.wrangler +.catalog-artifact coverage *.log tanstack.com-parity/ diff --git a/API-FRICTION.md b/API-FRICTION.md index 0ddc0f91..42c8c8ba 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -154,7 +154,7 @@ Each entry records: | F-116 | Build context was mistaken for resolved plot geometry | Documentation | resolved | | F-117 | Non-Cartesian examples duplicated coordinate engines | API | resolved | | F-118 | Serialized SVG discarded interaction semantics | API/Application | monitoring | -| F-119 | Catalog hosting crossed repository ownership | Tooling | resolved | +| F-119 | Catalog hosting crossed repository ownership | Tooling | monitoring | | F-120 | Key-only focus collapsed duplicate observations | API | resolved | | F-121 | SVG callback was not a rendering-pipeline boundary | API | resolved | | F-122 | Dense scene aggregation overflowed the call stack | API | resolved | @@ -567,7 +567,10 @@ Each entry records: exported or locally aliased tier constants are not assumed to propagate across modules. The catalog keeps metadata, exact TanStack loaders, and exact comparison loaders in separate registries; only `?compare=1` dynamically - imports the comparison registry. Do not rely on purity annotations, + imports the comparison registry. Production publication goes further: it + copies only the recursive ESM closure rooted at each exact implementation + entry. Raw-source wrappers, the standalone application, and the comparison + registry are not part of the artifact. Do not rely on purity annotations, minifier-specific interprocedural analysis, or a render-time branch to establish a bundle boundary. - Verification: emitted TanStack artifacts contain only the selected mark @@ -582,10 +585,10 @@ Each entry records: to zero for all five libraries, and `pnpm benchmark:check` passes. The catalog graph gate now proves exactly 100 TanStack, 68 Plot, 21 Recharts, and 11 ECharts implementations plus their isolated raw-source entries, with no - test/data/helper dynamic entries. The default catalog entry fell from about - 260 kB to 201 kB; fresh Chromium contexts request no comparison registry or - competitor package chunks for normal Plot, Recharts, ECharts, or embed - routes, while `?compare=1` requests the selected implementation and source. + test/data/helper dynamic entries. Every published TanStack root now receives + the same static-closure comparison-package check. The schema-v2 artifact + validator rejects unreferenced files, unsafe paths, missing imports, invalid + preloads, and a comparison module not marked debug-only. ### F-025 — Bundle maintenance clobbered the full comparison report @@ -1325,18 +1328,20 @@ Each entry records: - Status: resolved - Severity: medium - Owner: Tooling -- Observed in: publishing the conformance gallery as deep case and embed routes +- Observed in: publishing the conformance gallery as native case and embed + routes - Friction: the only strict case metadata parser lived beside `import.meta.glob`, so Node publication tooling either had to duplicate the schema or trust raw JSON. A duplicate validator would drift as interaction scenarios and additional reference renderers were added. - Decision: move metadata parsing into an environment-neutral TypeScript - module. The Vite catalog and Node static publisher now consume the same - parser; the publisher adds only publication invariants such as unique - IDs/orders and directory-name agreement. + module. The Vite catalog and Node artifact publisher consume the same parser; + the publisher adds only publication invariants such as unique IDs/orders, + directory-name agreement, safe routes, and a closed module allowlist. - Verification: strict typecheck passes, `catalog:check` validates all current - cases, and `catalog:build` generates matching detail pages, embeds, and - `catalog.json` from the same parsed metadata. + cases, and `catalog:build` generates schema-v2 `catalog.json` plus the exact + implementation closure from the same parsed metadata. TanStack.com renders + detail and embed routes from that structure rather than generated HTML. ### F-062 — Interaction checks were selector-bound @@ -2286,20 +2291,20 @@ Each entry records: and a documentation theme controlled by site state could not update an already interactive iframe. The intended production origin and base path were described but not exercised by the build gate. -- Decision: publish one versioned embed contract in `catalog.json`; fix the - production route at `https://tanstack.com/charts/catalog/`; parse explicit - query defaults and bounds; remove root and body width/background constraints - in embed mode; derive the exact parent origin from the HTTP(S) referrer; and - accept a versioned `set-theme` command only from that origin and - `window.parent`. A missing or opaque referrer disables messaging instead of - falling back to `*`. +- Decision: publish one versioned embed contract in the schema-v2 + `catalog.json`; fix the production route at + `https://tanstack.com/charts/catalog/`; parse explicit query defaults and + bounds; remove root and body width/background constraints in local embed + mode; derive the exact parent origin from the HTTP(S) referrer; and accept a + versioned `set-theme` command only from that origin and `window.parent`. A + missing or opaque referrer disables messaging instead of falling back to + `*`. TanStack.com owns the production embed route and response headers. - Verification: focused contract and route tests cover missing, invalid, bounded, and production-base inputs plus source/origin/case/version - rejection. The production catalog build emits and verifies 79 physical - detail routes, 79 direct embeds, canonical/noindex metadata, base-aware - assets, and the shared JSON contract. A real 280-pixel production-preview - iframe renders without horizontal overflow or catalog chrome, defaults to - 360 pixels when height is omitted, reports one exact-origin/source versioned + rejection. The generated artifact carries the shared contract and canonical + page/embed paths for every case. A real 280-pixel production-preview iframe + renders without horizontal overflow or catalog chrome, defaults to 360 + pixels when height is omitted, reports one exact-origin/source versioned ready event, accepts the trusted theme command, and ignores a wrong-case command. Typecheck and catalog metadata validation pass. - Follow-up: TanStack.com currently sends `X-Frame-Options: DENY` outside its @@ -2598,41 +2603,35 @@ Each entry records: ### F-119 — Catalog hosting crossed repository ownership -- Status: resolved +- Status: monitoring - Severity: high - Owner: Tooling/Integration - Observed in: publishing the executable catalog at `https://tanstack.com/charts/catalog/` - Friction: the catalog source, conformance contract, and production build belong to the Charts repository, while the public hostname is served by the - separate `tanstack.com` Cloudflare Worker. Copying source or generated assets - into that repository would couple releases and rollbacks, duplicate build - ownership, and make the site bundle responsible for reference libraries it - does not use. Pointing Workers Static Assets directly at the Vite `dist` - directory also fails below a path prefix because asset lookup retains the - complete public request path. -- Decision: deploy a separate assets-only `tanstack-charts-catalog` Worker from - this repository. Its `/charts/catalog*` route takes precedence over the main - Worker and covers query strings on the bare path. A generated, ignored - staging tree mirrors `dist` below `.catalog-deploy/charts/catalog/`; the - Worker version therefore owns its HTML, hashed assets, route manifest, - headers, and rollback atomically without a proxy or a `tanstack.com` source - dependency. Main-branch deployment waits for validation and the unfiltered - conformance matrix. Because Wrangler's deploy dry run does not boot workerd, - the gate also starts the pinned local runtime without persistent state and - applies the production smoke contract before upload. -- Verification: staging rejects the wrong origin or base path, symlinks, more - than 20,000 files, or an asset above 25 MiB. A Wrangler dry run validates the - static deployment. The local runtime caught and corrected a compatibility - date newer than Wrangler 4.103.0's bundled workerd supported. Worker version - `7df8f6a9-103b-40f7-9b24-315908a92ac3` then deployed 945 static assets to the - TanStack account. The live production smoke passed the bare and queried - canonical redirects, root, 100-case metadata, detail, frameable embed, - immutable hashed asset, security headers, and nearest 404 page. Route - propagation exceeded the first 30-second smoke window, so the deploy check - now allows 90 seconds. -- Follow-up: navigation and root-sitemap discovery may remain a small site - integration; neither should own catalog source or artifacts. + separate `tanstack.com` repository. Copying source into that repository would + couple releases and duplicate build ownership. A separate catalog Worker + preserved ownership but necessarily replaced the site's chrome, routing, + headers, cache policy, and content delivery behavior. +- Decision: treat the catalog as generated structured content. Charts CI builds + schema-v2 `catalog.json` plus only the recursively allowlisted implementation + modules, then replaces the generated `catalog-dist` branch after validation + and the unfiltered conformance matrix. TanStack.com's existing content + pipeline reads that branch, verifies hashes and limits, renders native routes + and embeds, and serves modules below an artifact-commit namespace. Charts + source and dependencies remain out of the site repository and default site + bundle. The previous Worker, staging tree, deployment scripts, credentials, + and route ownership are removed from the Charts workflow. +- Verification: the artifact generator records an exact Charts revision, + deterministic SHA-256 allowlist, safe repository source paths, recursive + imports, and debug-only comparison roots. Focused tests reject unsafe paths, + unreferenced assets, and public comparison modules. The loading gate checks + every TanStack root's static closure for reference cases or competitor + packages. Main-branch CI uploads the validated artifact and publishes only + `catalog.json` and `assets/*.js` to `catalog-dist`. +- Follow-up: keep monitoring through the TanStack.com cutover, production route + verification, and retirement of the previously deployed catalog Worker. ### F-120 — Key-only focus collapsed duplicate observations diff --git a/benchmarks/conformance/README.md b/benchmarks/conformance/README.md index 0613a3ea..335eb64c 100644 --- a/benchmarks/conformance/README.md +++ b/benchmarks/conformance/README.md @@ -50,7 +50,7 @@ pnpm dev:conformance # Validate publishable case metadata and route uniqueness pnpm catalog:check -# Build the static catalog, deep routes, embeds, and catalog.json +# Build the standalone authoring app and schema-v2 publication artifact pnpm catalog:build ``` @@ -73,32 +73,40 @@ accessibility, and edge behavior. ## Published catalog and documentation embeds -The catalog is one application driven by the same `case.json` files used by -conformance: - -| Route | Purpose | -| --------------- | ----------------------------------------------------------------- | -| `/` | Searchable case catalog; charts load only after selection | -| `/all/` | Every TanStack implementation on one page | -| `/charts/:id/` | One TanStack implementation with source and embed code | -| `/embed/:id/` | Chrome-free, responsive TanStack chart for an iframe | -| `/catalog.json` | Versioned metadata and page/embed paths for documentation tooling | - -Append the exact `?compare=1` debug flag to `/`, `/all/`, or `/charts/:id/` -to expose the reference implementation and source. Internal catalog links -preserve the flag; embed links intentionally omit it. The comparison registry -and its Plot, Recharts, and ECharts chunks are dynamically loaded only in that -mode. - -The build writes physical `index.html` files for every deep route, so direct -links work on a static host without rewrite rules. `404.html` retains -client-side route recovery. Set `CATALOG_BASE_PATH` when publishing below a -subdirectory and `CATALOG_ORIGIN` to emit absolute canonical URLs and a -sitemap. The production command fixes those values at -`https://tanstack.com/charts/catalog/`; local development keeps the Vite root -at `http://localhost:5194/`. `catalog.json` publishes the effective origin, -base path, embed protocol, query bounds, and per-case routes so documentation -components do not reconstruct URLs or duplicate an example manifest. +The Vite application remains the local authoring surface at +`http://localhost:5194/`. Production pages are native `tanstack.com` routes +rendered from the generated artifact: + +| Route | Purpose | +| ---------------------------------------------------------- | ------------------------------------------ | +| `/charts/catalog/` | Searchable case catalog | +| `/charts/catalog/all/` | Every TanStack implementation | +| `/charts/catalog/charts/:id/` | One implementation, source, and embed code | +| `/charts/catalog/embed/:id/` | Chrome-free responsive chart | +| `/charts/catalog/catalog.json` | Versioned content and runtime contract | +| `/charts/catalog/assets//assets/.js` | Allowlisted module from the exact revision | + +Append the exact `?compare=1` debug flag to the catalog, all-cases, or detail +route to expose the reference implementation. Comparison modules remain +separate roots and are marked `visibility: "debug"` in the artifact; the site +must not serialize, preload, or import them without that flag. + +`catalog.json` schema version 2 contains: + +- the exact 40-character Charts revision and repository; +- the runtime `mount` export contract; +- the production origin, route base, and asset base; +- the versioned embed protocol; +- parsed case metadata and canonical page/embed routes; +- immutable repository source paths; +- one TanStack module and one debug-only comparison module per case; +- a byte count, SHA-256 digest, static imports, and dynamic imports for every + allowlisted module. + +Only the recursive ESM closure of the 200 case implementations is published. +The standalone application entry, route code, raw-source wrappers, tests, CSS, +and unrelated Vite output are excluded. The site resolves code from the +recorded Charts revision rather than shipping raw-source JavaScript wrappers. An embed accepts `theme=system|light|dark`, `height=120..1200`, and an optional numeric `revision`. Width always follows the iframe container: @@ -155,44 +163,28 @@ Adding or changing a case updates every catalog surface automatically. invalid schemas, duplicate IDs or orders, and case IDs that drift from their directory names. -## Production hosting +## Generated content publication -The catalog is deployed from this repository as the separate Cloudflare Static -Assets Worker `tanstack-charts-catalog`. Its -`tanstack.com/charts/catalog*` route takes precedence over the main -`tanstack-com` Worker and includes query strings on the bare catalog path. No -catalog source or generated asset is copied into the `tanstack.com` -repository. - -Workers Static Assets matches the complete request path, so `catalog:stage` -mirrors the production build below `.catalog-deploy/charts/catalog/` before -deployment. The ignored staging directory is never committed. Fingerprinted -assets receive immutable caching; HTML and metadata retain Cloudflare's -revalidating default. Catalog pages deny framing, while the explicit embed -routes remain frameable. +Charts owns the examples and build. TanStack.com owns the page routes, chrome, +SEO, security headers, and embed response. The repositories meet through a +generated `catalog-dist` branch containing only `catalog.json` and its +allowlisted `assets/*.js` closure. ```sh -# Build, mirror the public path, and validate the Worker bundle locally +# Build and validate the exact publication artifact pnpm catalog:build -pnpm catalog:deploy:check -# Prove the default/debug module and network boundary in Chromium +# Prove local authoring isolation and the published module graph pnpm catalog:loading:check - -# Deploy with an authenticated Wrangler session -pnpm catalog:deploy - -# Verify root, detail, embed, asset, metadata, and 404 behavior -pnpm catalog:smoke ``` -Main-branch CI deploys only after validation and the unfiltered standard -conformance matrix. The `charts-catalog-production` GitHub environment must -provide a `CLOUDFLARE_API_TOKEN` scoped to the catalog Worker and the -`tanstack.com` route; the non-secret account ID stays in the Wrangler config. -Rollbacks use the prior -`tanstack-charts-catalog` Worker version; the catalog has no mutable runtime -state. +Main-branch CI publishes a new generated commit only after validation and the +unfiltered standard conformance matrix pass. TanStack.com's existing content +pipeline reads that branch and verifies the schema, revision, module allowlist, +sizes, and hashes before serving it. It composes `site.assetBasePath`, the +resolved `catalog-dist` commit SHA, and each relative module path into the +immutable asset URL. A rollback points `catalog-dist` back to a prior generated +commit; the catalog has no mutable runtime state. ## What is and is not equivalent diff --git a/deploy/catalog/_headers b/deploy/catalog/_headers deleted file mode 100644 index 1f35fe9e..00000000 --- a/deploy/catalog/_headers +++ /dev/null @@ -1,16 +0,0 @@ -/charts/catalog/* - X-Frame-Options: DENY - X-Content-Type-Options: nosniff - X-XSS-Protection: 1; mode=block - Referrer-Policy: strict-origin-when-cross-origin - Permissions-Policy: camera=(), microphone=(), geolocation=() - Strict-Transport-Security: max-age=31536000; includeSubDomains - -/charts/catalog/embed/* - ! X-Frame-Options - -/charts/catalog/assets/* - Cache-Control: public, max-age=31536000, immutable - -https://:version.:subdomain.workers.dev/* - X-Robots-Tag: noindex diff --git a/examples/conformance/package.json b/examples/conformance/package.json index cbf3d8a2..41f6ec34 100644 --- a/examples/conformance/package.json +++ b/examples/conformance/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "build": "vite build && node ../../scripts/build-conformance-site.mjs && node ../../scripts/check-conformance-site.mjs", - "check:catalog": "node ../../scripts/build-conformance-site.mjs --check", + "build": "vite build && node ../../scripts/build-conformance-artifact.mjs && node ../../scripts/check-conformance-artifact.mjs", + "check:catalog": "node ../../scripts/build-conformance-artifact.mjs --check", "dev": "vite --host --port 5194 --strictPort" }, "dependencies": { diff --git a/examples/conformance/vite.config.ts b/examples/conformance/vite.config.ts index e3e9f95b..7b1deeca 100644 --- a/examples/conformance/vite.config.ts +++ b/examples/conformance/vite.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vite' export default defineConfig({ base: normalizeBasePath(process.env.CATALOG_BASE_PATH ?? '/'), build: { + manifest: true, target: 'es2022', }, }) diff --git a/package.json b/package.json index 397d4b3c..79e222a1 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,9 @@ "bundle:check": "node scripts/measure-bundles.mjs --check", "bundle:update-baseline": "node scripts/measure-bundles.mjs --update-baseline", "canvas:check": "node scripts/check-canvas-renderer.mjs", - "catalog:build": "CATALOG_BASE_PATH=/charts/catalog/ CATALOG_ORIGIN=https://tanstack.com pnpm --filter @charts-poc/conformance-example build", + "catalog:build": "CATALOG_BASE_PATH=/charts/catalog/ pnpm --filter @charts-poc/conformance-example build", "catalog:check": "pnpm --filter @charts-poc/conformance-example check:catalog", - "catalog:deploy": "pnpm catalog:build && pnpm catalog:deploy:check && wrangler deploy --config wrangler.catalog.jsonc", - "catalog:deploy:check": "pnpm catalog:stage && node scripts/check-local-catalog-worker.mjs && wrangler deploy --dry-run --outdir .catalog-worker --config wrangler.catalog.jsonc", - "catalog:loading:check": "pnpm catalog:stage && node scripts/check-catalog-loading.mjs", - "catalog:smoke": "node scripts/check-catalog-deployment.mjs", - "catalog:stage": "node scripts/stage-conformance-deployment.mjs", + "catalog:loading:check": "node scripts/check-catalog-loading.mjs", "dev:charts-react": "pnpm --filter @charts-poc/react-example dev", "dev:charts-octane": "pnpm --filter @charts-poc/octane-example dev", "dev:conformance": "pnpm --filter @charts-poc/conformance-example dev", diff --git a/scripts/build-conformance-artifact.mjs b/scripts/build-conformance-artifact.mjs new file mode 100644 index 00000000..bf7c40f0 --- /dev/null +++ b/scripts/build-conformance-artifact.mjs @@ -0,0 +1,160 @@ +import { execFile } from 'node:child_process' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +import { parseConformanceCaseMeta } from '../benchmarks/conformance/metadata.ts' +import { chartEmbedContract } from '../examples/conformance/src/embed-contract.ts' +import { + attachEmbedContract, + createCatalogArtifact, + serializeCatalogManifest, + validateCatalogArtifactManifest, +} from './catalog-artifact.mjs' + +const execFileAsync = promisify(execFile) +const rootDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +) +const casesDirectory = path.join( + rootDirectory, + 'benchmarks', + 'conformance', + 'cases', +) +const buildDirectory = path.join( + rootDirectory, + 'examples', + 'conformance', + 'dist', +) +const artifactDirectory = path.join(rootDirectory, '.catalog-artifact') + +const checkOnly = process.argv.includes('--check') +const cases = await readCases() + +if (checkOnly) { + validateCaseIdentities(cases) + console.log(`Validated ${cases.length} publishable catalog cases.`) + process.exit(0) +} + +const viteManifest = JSON.parse( + await fs.readFile( + path.join(buildDirectory, '.vite', 'manifest.json'), + 'utf8', + ), +) +const revision = await readRevision() +const artifact = await createCatalogArtifact({ + cases, + revision, + viteManifest, + readAsset: (assetPath) => + fs.readFile(path.join(buildDirectory, ...assetPath.split('/'))), +}) +const catalog = attachEmbedContract(artifact.catalog, chartEmbedContract) +const summary = validateCatalogArtifactManifest(catalog) + +assertSafeArtifactDirectory(artifactDirectory) +await fs.rm(artifactDirectory, { recursive: true, force: true }) +await fs.mkdir(artifactDirectory, { recursive: true }) + +for (const [assetPath, content] of artifact.assetContents) { + const destination = path.join(artifactDirectory, ...assetPath.split('/')) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(destination, content) +} + +await fs.writeFile( + path.join(artifactDirectory, 'catalog.json'), + serializeCatalogManifest(catalog), + 'utf8', +) + +console.log( + `Generated schema v2 catalog artifact for ${summary.caseCount} cases in ${summary.assetCount} modules (${formatBytes(summary.assetBytes)}) at ${revision}.`, +) + +async function readCases() { + const directories = ( + await fs.readdir(casesDirectory, { withFileTypes: true }) + ) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + + return Promise.all( + directories.map(async (directory) => { + const metadataPath = path.join(casesDirectory, directory, 'case.json') + let source + try { + source = await fs.readFile(metadataPath, 'utf8') + } catch (error) { + throw new Error(`Missing catalog metadata: ${metadataPath}`, { + cause: error, + }) + } + + let rawMetadata + try { + rawMetadata = JSON.parse(source) + } catch (error) { + throw new Error(`Invalid JSON in ${metadataPath}`, { cause: error }) + } + + return { + directory, + metadata: parseConformanceCaseMeta(rawMetadata, metadataPath), + } + }), + ) +} + +function validateCaseIdentities(entries) { + const ids = new Set() + const orders = new Set() + + for (const { directory, metadata } of entries) { + if (metadata.id !== directory) { + throw new Error( + `Catalog id "${metadata.id}" must match directory "${directory}"`, + ) + } + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(metadata.id)) { + throw new Error( + `Catalog id "${metadata.id}" must use lowercase URL-safe words separated by hyphens`, + ) + } + if (ids.has(metadata.id)) { + throw new Error(`Duplicate catalog id "${metadata.id}"`) + } + if (orders.has(metadata.order)) { + throw new Error(`Duplicate catalog order ${metadata.order}`) + } + ids.add(metadata.id) + orders.add(metadata.order) + } +} + +async function readRevision() { + const configured = + process.env.CATALOG_SOURCE_REVISION ?? process.env.GITHUB_SHA + if (configured) return configured.trim().toLowerCase() + + const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { + cwd: rootDirectory, + }) + return stdout.trim().toLowerCase() +} + +function assertSafeArtifactDirectory(directory) { + if (directory !== path.join(rootDirectory, '.catalog-artifact')) { + throw new Error('catalog artifact output must remain .catalog-artifact') + } +} + +function formatBytes(bytes) { + return `${(bytes / 1024 / 1024).toFixed(2)} MiB` +} diff --git a/scripts/build-conformance-site.mjs b/scripts/build-conformance-site.mjs deleted file mode 100644 index 58ccaeee..00000000 --- a/scripts/build-conformance-site.mjs +++ /dev/null @@ -1,311 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { parseConformanceCaseMeta } from '../benchmarks/conformance/metadata.ts' -import { chartEmbedContract } from '../examples/conformance/src/embed-contract.ts' - -const rootDirectory = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -) -const casesDirectory = path.join( - rootDirectory, - 'benchmarks', - 'conformance', - 'cases', -) -const outputDirectory = path.join( - rootDirectory, - 'examples', - 'conformance', - 'dist', -) -const checkOnly = process.argv.includes('--check') -const basePath = normalizeBasePath(process.env.CATALOG_BASE_PATH ?? '/') -const publicOrigin = normalizeOrigin(process.env.CATALOG_ORIGIN) - -const cases = await readCases() -validateCases(cases) - -if (checkOnly) { - console.log(`Validated ${cases.length} publishable catalog cases.`) - process.exit(0) -} - -await generateSite(cases) -console.log( - `Generated ${cases.length} chart pages, ${cases.length} embeds, and catalog.json.`, -) - -async function readCases() { - const directories = ( - await fs.readdir(casesDirectory, { withFileTypes: true }) - ) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort() - - return Promise.all( - directories.map(async (directory) => { - const metadataPath = path.join(casesDirectory, directory, 'case.json') - let source - try { - source = await fs.readFile(metadataPath, 'utf8') - } catch (error) { - throw new Error(`Missing catalog metadata: ${metadataPath}`, { - cause: error, - }) - } - - let rawMetadata - try { - rawMetadata = JSON.parse(source) - } catch (error) { - throw new Error(`Invalid JSON in ${metadataPath}`, { cause: error }) - } - const metadata = parseConformanceCaseMeta(rawMetadata, metadataPath) - - return { directory, metadata, metadataPath } - }), - ) -} - -function validateCases(entries) { - const ids = new Map() - const orders = new Map() - - for (const entry of entries) { - const { directory, metadata, metadataPath } = entry - if (metadata.id !== directory) { - throw new Error( - `Catalog id "${metadata.id}" must match directory "${directory}"`, - ) - } - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(metadata.id)) { - throw new Error( - `Catalog id "${metadata.id}" must use lowercase URL-safe words separated by hyphens`, - ) - } - if (ids.has(metadata.id)) { - throw new Error( - `Duplicate catalog id "${metadata.id}" in ${metadataPath} and ${ids.get(metadata.id)}`, - ) - } - if (orders.has(metadata.order)) { - throw new Error( - `Duplicate catalog order ${metadata.order} in ${metadataPath} and ${orders.get(metadata.order)}`, - ) - } - - ids.set(metadata.id, metadataPath) - orders.set(metadata.order, metadataPath) - } -} - -async function generateSite(entries) { - const shellPath = path.join(outputDirectory, 'index.html') - let shell - try { - shell = await fs.readFile(shellPath, 'utf8') - } catch (error) { - throw new Error( - 'Build the Vite catalog before generating its static routes.', - { cause: error }, - ) - } - - const sorted = [...entries].sort( - (left, right) => left.metadata.order - right.metadata.order, - ) - const rootRoute = basePath - const rootMetadata = { - title: 'TanStack Charts Catalog', - description: - 'Browse executable TanStack Charts examples, source, and embeddable proofs.', - route: rootRoute, - noIndex: false, - } - - await fs.writeFile( - shellPath, - renderDocumentMetadata(shell, rootMetadata), - 'utf8', - ) - await writeRoute(shell, 'all', { - title: 'All charts · TanStack Charts Catalog', - description: - 'Render the complete TanStack Charts catalog with live source and controls.', - route: routePath('all'), - noIndex: false, - }) - - for (const { metadata } of sorted) { - await writeRoute(shell, path.join('charts', metadata.id), { - title: `${metadata.title} · TanStack Charts Catalog`, - description: metadata.intent, - route: routePath('charts', metadata.id), - noIndex: false, - }) - await writeRoute(shell, path.join('embed', metadata.id), { - title: `${metadata.title} · TanStack Charts`, - description: metadata.intent, - route: routePath('embed', metadata.id), - noIndex: true, - }) - } - - await fs.writeFile( - path.join(outputDirectory, '404.html'), - renderDocumentMetadata(shell, { - title: 'Chart not found · TanStack Charts Catalog', - description: 'Chart not found.', - route: rootRoute, - noIndex: true, - }), - 'utf8', - ) - - await fs.writeFile( - path.join(outputDirectory, 'catalog.json'), - `${JSON.stringify( - { - schemaVersion: 1, - site: { - origin: publicOrigin || null, - basePath, - }, - embed: chartEmbedContract, - cases: sorted.map(({ metadata }) => ({ - ...metadata, - routes: { - page: routePath('charts', metadata.id), - embed: routePath('embed', metadata.id), - }, - })), - }, - null, - 2, - )}\n`, - 'utf8', - ) - - if (publicOrigin) { - const routes = [ - rootRoute, - routePath('all'), - ...sorted.map(({ metadata }) => routePath('charts', metadata.id)), - ] - const sitemap = [ - '', - '', - ...routes.map( - (route) => ` ${escapeXml(absoluteUrl(route))}`, - ), - '', - '', - ].join('\n') - await fs.writeFile( - path.join(outputDirectory, 'sitemap.xml'), - sitemap, - 'utf8', - ) - } -} - -async function writeRoute(shell, directory, metadata) { - const routeDirectory = path.join(outputDirectory, directory) - await fs.mkdir(routeDirectory, { recursive: true }) - await fs.writeFile( - path.join(routeDirectory, 'index.html'), - renderDocumentMetadata(shell, metadata), - 'utf8', - ) -} - -function renderDocumentMetadata(shell, { title, description, route, noIndex }) { - let html = shell - html = replaceRequired( - html, - /[\s\S]*?<\/title>/, - `<title>${escapeHtml(title)}`, - 'title', - ) - html = replaceRequired( - html, - //, - ``, - 'description', - ) - html = replaceRequired( - html, - //, - ``, - 'robots', - ) - html = replaceRequired( - html, - //, - ``, - 'og:title', - ) - html = replaceRequired( - html, - //, - ``, - 'og:description', - ) - html = replaceRequired( - html, - //, - ``, - 'og:url', - ) - html = replaceRequired( - html, - //, - ``, - 'canonical', - ) - return html -} - -function replaceRequired(value, pattern, replacement, label) { - if (!pattern.test(value)) { - throw new Error(`Catalog HTML is missing the ${label} metadata marker.`) - } - return value.replace(pattern, replacement) -} - -function routePath(...segments) { - return `${basePath}${segments.map(encodeURIComponent).join('/')}/` -} - -function absoluteUrl(route) { - return publicOrigin ? `${publicOrigin}${route}` : route -} - -function normalizeBasePath(value) { - const leadingSlash = value.startsWith('/') ? value : `/${value}` - return leadingSlash.endsWith('/') ? leadingSlash : `${leadingSlash}/` -} - -function normalizeOrigin(value) { - if (!value) return '' - const url = new URL(value) - return url.origin -} - -function escapeHtml(value) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') -} - -function escapeAttribute(value) { - return escapeHtml(value).replaceAll('"', '"') -} - -function escapeXml(value) { - return escapeAttribute(value).replaceAll("'", ''') -} diff --git a/scripts/catalog-artifact.mjs b/scripts/catalog-artifact.mjs new file mode 100644 index 00000000..965b22a1 --- /dev/null +++ b/scripts/catalog-artifact.mjs @@ -0,0 +1,520 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' + +export const catalogArtifactSchemaVersion = 2 +export const catalogModuleContractVersion = 1 +export const catalogSourceRepository = 'tanstack/charts' +export const catalogBasePath = '/charts/catalog/' +export const catalogOrigin = 'https://tanstack.com' +export const catalogArtifactFileLimit = 1_000 +export const catalogArtifactFileSizeLimit = 1024 * 1024 +export const catalogArtifactTotalSizeLimit = 5 * 1024 * 1024 + +const revisionPattern = /^[a-f0-9]{40}$/ +const caseIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const assetPathPattern = + /^assets\/[A-Za-z0-9][A-Za-z0-9._-]*-[A-Za-z0-9_-]{5,}\.js$/ + +export async function createCatalogArtifact({ + cases, + revision, + viteManifest, + readAsset, +}) { + assertRevision(revision) + validateCaseEntries(cases) + + const manifestEntries = new Map(Object.entries(viteManifest)) + const rootKeys = new Set() + const publishedCases = [] + + for (const { metadata } of [...cases].sort( + (left, right) => left.metadata.order - right.metadata.order, + )) { + const referenceRenderer = metadata.referenceRenderer ?? 'observable-plot' + const referenceFile = rendererFileName(referenceRenderer) + const tanstackSource = caseSourcePath(metadata.id, 'tanstack') + const referenceSource = caseSourcePath(metadata.id, referenceFile) + const tanstackKey = findManifestKey(manifestEntries, tanstackSource) + const referenceKey = findManifestKey(manifestEntries, referenceSource) + rootKeys.add(tanstackKey) + rootKeys.add(referenceKey) + + const tanstackEntry = requiredManifestEntry(manifestEntries, tanstackKey) + const referenceEntry = requiredManifestEntry(manifestEntries, referenceKey) + + publishedCases.push({ + ...metadata, + routes: { + page: `${catalogBasePath}charts/${encodeURIComponent(metadata.id)}/`, + embed: `${catalogBasePath}embed/${encodeURIComponent(metadata.id)}/`, + }, + code: { + tanstack: tanstackSource, + reference: referenceSource, + }, + modules: { + tanstack: { + path: normalizeAssetPath(tanstackEntry.file), + preload: staticClosure(tanstackKey, manifestEntries, false).map( + (key) => + normalizeAssetPath( + requiredManifestEntry(manifestEntries, key).file, + ), + ), + }, + comparison: { + renderer: referenceRenderer, + path: normalizeAssetPath(referenceEntry.file), + preload: staticClosure(referenceKey, manifestEntries, false).map( + (key) => + normalizeAssetPath( + requiredManifestEntry(manifestEntries, key).file, + ), + ), + visibility: 'debug', + }, + }, + }) + } + + const assetKeys = new Set() + for (const rootKey of rootKeys) { + for (const key of completeClosure(rootKey, manifestEntries)) { + assetKeys.add(key) + } + } + + const assetContents = new Map() + const assetRecords = [] + + for (const key of [...assetKeys].sort((left, right) => { + const leftFile = requiredManifestEntry(manifestEntries, left).file + const rightFile = requiredManifestEntry(manifestEntries, right).file + return compareStrings(leftFile, rightFile) + })) { + const entry = requiredManifestEntry(manifestEntries, key) + const assetPath = normalizeAssetPath(entry.file) + assert( + !assetContents.has(assetPath), + `multiple Vite entries emit ${assetPath}`, + ) + assert( + !entry.css?.length, + `catalog implementation asset ${assetPath} unexpectedly requires CSS`, + ) + assert( + !entry.assets?.length, + `catalog implementation asset ${assetPath} unexpectedly requires a non-module asset`, + ) + + const content = await readAsset(assetPath) + assert( + ArrayBuffer.isView(content) && content.BYTES_PER_ELEMENT === 1, + `catalog asset reader did not return bytes for ${assetPath}`, + ) + assetContents.set(assetPath, content) + assetRecords.push([ + assetPath, + { + bytes: content.byteLength, + sha256: createHash('sha256').update(content).digest('hex'), + imports: manifestImportPaths(entry.imports, manifestEntries), + dynamicImports: manifestImportPaths( + entry.dynamicImports, + manifestEntries, + ), + }, + ]) + } + + const catalog = { + schemaVersion: catalogArtifactSchemaVersion, + revision, + source: { + repo: catalogSourceRepository, + ref: revision, + }, + runtime: { + contractVersion: catalogModuleContractVersion, + export: 'mount', + }, + site: { + origin: catalogOrigin, + basePath: catalogBasePath, + assetBasePath: `${catalogBasePath}assets/`, + }, + embed: undefined, + assets: Object.fromEntries(assetRecords), + cases: publishedCases, + } + + return { assetContents, catalog } +} + +export function attachEmbedContract(catalog, embed) { + return { + ...catalog, + embed, + } +} + +export function validateCatalogArtifactManifest(catalog) { + assert(isRecord(catalog), 'catalog.json must contain an object') + assert( + catalog.schemaVersion === catalogArtifactSchemaVersion, + `catalog.json schemaVersion must be ${catalogArtifactSchemaVersion}`, + ) + assertRevision(catalog.revision) + assert( + isRecord(catalog.source) && + catalog.source.repo === catalogSourceRepository && + catalog.source.ref === catalog.revision, + 'catalog.json source must identify the exact Charts revision', + ) + assert( + isRecord(catalog.runtime) && + catalog.runtime.contractVersion === catalogModuleContractVersion && + catalog.runtime.export === 'mount', + 'catalog.json runtime contract is invalid', + ) + assert( + isRecord(catalog.site) && + catalog.site.origin === catalogOrigin && + catalog.site.basePath === catalogBasePath && + catalog.site.assetBasePath === `${catalogBasePath}assets/`, + 'catalog.json site contract is invalid', + ) + assert(isRecord(catalog.embed), 'catalog.json embed contract is missing') + assert(isRecord(catalog.assets), 'catalog.json assets must be an object') + assert(Array.isArray(catalog.cases), 'catalog.json cases must be an array') + assert(catalog.cases.length > 0, 'catalog.json must contain cases') + + const assetEntries = Object.entries(catalog.assets) + assert( + assetEntries.length <= catalogArtifactFileLimit, + `catalog has ${assetEntries.length} assets; limit is ${catalogArtifactFileLimit}`, + ) + + let totalAssetBytes = 0 + for (const [assetPath, value] of assetEntries) { + assertAssetPath(assetPath) + assert(isRecord(value), `catalog asset ${assetPath} must be an object`) + assert( + Number.isSafeInteger(value.bytes) && + value.bytes >= 0 && + value.bytes <= catalogArtifactFileSizeLimit, + `catalog asset ${assetPath} has invalid bytes`, + ) + totalAssetBytes += value.bytes + assert( + typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.sha256), + `catalog asset ${assetPath} has invalid sha256`, + ) + for (const field of ['imports', 'dynamicImports']) { + assert( + Array.isArray(value[field]) && + value[field].every((entry) => typeof entry === 'string'), + `catalog asset ${assetPath} has invalid ${field}`, + ) + for (const importedPath of value[field]) { + assertAssetPath(importedPath) + assert( + importedPath in catalog.assets, + `catalog asset ${assetPath} imports missing ${importedPath}`, + ) + } + } + } + assert( + totalAssetBytes <= catalogArtifactTotalSizeLimit, + `catalog assets total ${totalAssetBytes} bytes; limit is ${catalogArtifactTotalSizeLimit}`, + ) + + const ids = new Set() + const orders = new Set() + const roots = new Set() + const referenceCounts = { + 'observable-plot': 0, + recharts: 0, + echarts: 0, + } + + for (const entry of catalog.cases) { + assert(isRecord(entry), 'every catalog case must be an object') + assert( + typeof entry.id === 'string' && caseIdPattern.test(entry.id), + 'every catalog case must have a URL-safe id', + ) + assert(!ids.has(entry.id), `duplicate catalog case id ${entry.id}`) + ids.add(entry.id) + assert( + Number.isSafeInteger(entry.order), + `catalog case ${entry.id} has invalid order`, + ) + assert( + !orders.has(entry.order), + `duplicate catalog case order ${entry.order}`, + ) + orders.add(entry.order) + assert( + isRecord(entry.routes) && + entry.routes.page === + `${catalogBasePath}charts/${encodeURIComponent(entry.id)}/` && + entry.routes.embed === + `${catalogBasePath}embed/${encodeURIComponent(entry.id)}/`, + `catalog case ${entry.id} has invalid routes`, + ) + assert( + isRecord(entry.code) && + entry.code.tanstack === caseSourcePath(entry.id, 'tanstack') && + typeof entry.code.reference === 'string' && + isSafeRepositoryPath(entry.code.reference), + `catalog case ${entry.id} has invalid source paths`, + ) + assert( + isRecord(entry.modules) && + isRecord(entry.modules.tanstack) && + isRecord(entry.modules.comparison), + `catalog case ${entry.id} has invalid modules`, + ) + validateModuleReference( + entry.modules.tanstack, + catalog.assets, + `catalog case ${entry.id} TanStack module`, + ) + const comparison = entry.modules.comparison + validateModuleReference( + comparison, + catalog.assets, + `catalog case ${entry.id} comparison module`, + ) + assert( + comparison.visibility === 'debug', + `catalog case ${entry.id} comparison must be debug-only`, + ) + assert( + comparison.renderer === 'observable-plot' || + comparison.renderer === 'recharts' || + comparison.renderer === 'echarts', + `catalog case ${entry.id} has invalid comparison renderer`, + ) + assert( + entry.code.reference === + caseSourcePath(entry.id, rendererFileName(comparison.renderer)), + `catalog case ${entry.id} comparison source does not match its renderer`, + ) + referenceCounts[comparison.renderer] += 1 + roots.add(entry.modules.tanstack.path) + roots.add(comparison.path) + } + + const reachableAssets = new Set() + const visit = (assetPath) => { + if (reachableAssets.has(assetPath)) return + reachableAssets.add(assetPath) + const asset = catalog.assets[assetPath] + for (const dependency of [...asset.imports, ...asset.dynamicImports]) { + visit(dependency) + } + } + for (const root of roots) visit(root) + assert( + reachableAssets.size === assetEntries.length, + 'catalog artifact contains assets outside the implementation closure', + ) + + return { + assetBytes: totalAssetBytes, + assetCount: assetEntries.length, + caseCount: catalog.cases.length, + referenceCounts, + } +} + +export function serializeCatalogManifest(catalog) { + return `${JSON.stringify(catalog, null, 2)}\n` +} + +function validateModuleReference(module, assets, label) { + assertAssetPath(module.path) + assert(module.path in assets, `${label} is not in the asset allowlist`) + assert( + Array.isArray(module.preload) && + module.preload.every((entry) => typeof entry === 'string'), + `${label} has invalid preload entries`, + ) + for (const preload of module.preload) { + assertAssetPath(preload) + assert(preload in assets, `${label} preloads missing ${preload}`) + } + + const expected = staticAssetClosure(module.path, assets) + assert( + JSON.stringify(module.preload) === JSON.stringify(expected), + `${label} preload closure is invalid`, + ) +} + +function staticAssetClosure(root, assets) { + const visited = new Set([root]) + const visit = (assetPath) => { + for (const importedPath of assets[assetPath].imports) { + if (visited.has(importedPath)) continue + visited.add(importedPath) + visit(importedPath) + } + } + visit(root) + visited.delete(root) + return [...visited].sort(compareStrings) +} + +function validateCaseEntries(entries) { + const ids = new Set() + const orders = new Set() + + for (const { directory, metadata } of entries) { + assert( + metadata.id === directory, + `catalog id "${metadata.id}" must match directory "${directory}"`, + ) + assert( + caseIdPattern.test(metadata.id), + `catalog id "${metadata.id}" must use lowercase URL-safe words separated by hyphens`, + ) + assert(!ids.has(metadata.id), `duplicate catalog id "${metadata.id}"`) + assert( + !orders.has(metadata.order), + `duplicate catalog order ${metadata.order}`, + ) + ids.add(metadata.id) + orders.add(metadata.order) + } +} + +function completeClosure(root, entries) { + const visited = new Set() + const visit = (key) => { + if (visited.has(key)) return + visited.add(key) + const entry = requiredManifestEntry(entries, key) + for (const dependency of [ + ...(entry.imports ?? []), + ...(entry.dynamicImports ?? []), + ]) { + visit(dependency) + } + } + visit(root) + return [...visited] +} + +function staticClosure(root, entries, includeRoot = true) { + const visited = new Set() + const visit = (key) => { + if (visited.has(key)) return + visited.add(key) + const entry = requiredManifestEntry(entries, key) + for (const dependency of entry.imports ?? []) visit(dependency) + } + visit(root) + if (!includeRoot) visited.delete(root) + return [...visited].sort((left, right) => { + const leftFile = requiredManifestEntry(entries, left).file + const rightFile = requiredManifestEntry(entries, right).file + return compareStrings(leftFile, rightFile) + }) +} + +function manifestImportPaths(keys, entries) { + return [...(keys ?? [])] + .map((key) => normalizeAssetPath(requiredManifestEntry(entries, key).file)) + .sort(compareStrings) +} + +function findManifestKey(entries, sourcePath) { + const matches = [...entries].filter(([, entry]) => { + const source = normalizeRepositoryPath(entry.src ?? '') + return source.endsWith(`/${sourcePath}`) || source === sourcePath + }) + assert( + matches.length === 1, + `expected one Vite entry for ${sourcePath}, received ${matches.length}`, + ) + const [key, entry] = matches[0] + assert( + entry.isDynamicEntry === true, + `Vite entry for ${sourcePath} must be dynamic`, + ) + return key +} + +function requiredManifestEntry(entries, key) { + const entry = entries.get(key) + assert(entry, `Vite manifest is missing ${key}`) + assert(isRecord(entry), `Vite manifest entry ${key} is invalid`) + assert( + typeof entry.file === 'string', + `Vite manifest entry ${key} has no file`, + ) + return entry +} + +function rendererFileName(renderer) { + return renderer === 'observable-plot' ? 'plot' : renderer +} + +function caseSourcePath(id, rendererFile) { + return `benchmarks/conformance/cases/${id}/${rendererFile}.ts` +} + +function normalizeAssetPath(value) { + const normalized = normalizeRepositoryPath(value) + assertAssetPath(normalized) + return normalized +} + +function assertAssetPath(value) { + assert( + typeof value === 'string' && + assetPathPattern.test(value) && + isSafeRepositoryPath(value), + `invalid catalog asset path ${String(value)}`, + ) +} + +function normalizeRepositoryPath(value) { + return value.replaceAll('\\', '/').replace(/^\.?\//, '') +} + +function isSafeRepositoryPath(value) { + if (typeof value !== 'string' || value.length === 0) return false + const normalized = path.posix.normalize(value) + return ( + normalized === value && + !normalized.startsWith('/') && + normalized !== '..' && + !normalized.startsWith('../') && + !normalized.includes('/../') + ) +} + +function assertRevision(value) { + assert( + typeof value === 'string' && revisionPattern.test(value), + 'catalog revision must be a lowercase 40-character Git SHA', + ) +} + +function compareStrings(left, right) { + return left < right ? -1 : left > right ? 1 : 0 +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assert(condition, message) { + if (!condition) throw new Error(message) +} diff --git a/scripts/catalog-artifact.test.mjs b/scripts/catalog-artifact.test.mjs new file mode 100644 index 00000000..a0e9adcd --- /dev/null +++ b/scripts/catalog-artifact.test.mjs @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + attachEmbedContract, + createCatalogArtifact, + validateCatalogArtifactManifest, +} from './catalog-artifact.mjs' + +const revision = 'a'.repeat(40) +const cases = [ + { + directory: '01-line', + metadata: { + schemaVersion: 1, + order: 1, + id: '01-line', + title: 'Line', + family: 'trend', + intent: 'Show change.', + support: 'native', + features: ['line'], + geometry: [{ role: 'line', count: 1 }], + source: { title: 'Plot line', url: 'https://example.com/line' }, + ai: { create: 'Create it.', maintain: 'Maintain it.' }, + }, + }, +] +const viteManifest = { + '../../benchmarks/conformance/cases/01-line/tanstack.ts': { + file: 'assets/tanstack-AAAA1111.js', + src: '../../benchmarks/conformance/cases/01-line/tanstack.ts', + isDynamicEntry: true, + imports: ['_shared.js'], + }, + '../../benchmarks/conformance/cases/01-line/plot.ts': { + file: 'assets/plot-BBBB2222.js', + src: '../../benchmarks/conformance/cases/01-line/plot.ts', + isDynamicEntry: true, + imports: ['_plot.js', '_shared.js'], + }, + '../../benchmarks/conformance/cases/01-line/tanstack.ts?raw': { + file: 'assets/tanstack-source-CCCC3333.js', + src: '../../benchmarks/conformance/cases/01-line/tanstack.ts?raw', + isDynamicEntry: true, + }, + '_shared.js': { + file: 'assets/shared-DDDD4444.js', + }, + '_plot.js': { + file: 'assets/plot-runtime-EEEE5555.js', + imports: ['_shared.js'], + }, + 'index.html': { + file: 'assets/index-FFFF6666.js', + isEntry: true, + }, +} +const contents = new Map([ + ['assets/tanstack-AAAA1111.js', bytes('tanstack')], + ['assets/plot-BBBB2222.js', bytes('plot')], + ['assets/shared-DDDD4444.js', bytes('shared')], + ['assets/plot-runtime-EEEE5555.js', bytes('plot-runtime')], +]) + +describe('catalog artifact', () => { + it('publishes only the recursive implementation closure', async () => { + const artifact = await createArtifact() + const catalog = attachEmbedContract(artifact.catalog, { + protocol: { version: 1 }, + }) + const summary = validateCatalogArtifactManifest(catalog) + + expect([...artifact.assetContents.keys()].sort()).toEqual( + [...contents.keys()].sort(), + ) + expect(Object.keys(catalog.assets).sort()).toEqual( + [...contents.keys()].sort(), + ) + expect(catalog.cases[0].modules).toEqual({ + tanstack: { + path: 'assets/tanstack-AAAA1111.js', + preload: ['assets/shared-DDDD4444.js'], + }, + comparison: { + renderer: 'observable-plot', + path: 'assets/plot-BBBB2222.js', + preload: [ + 'assets/plot-runtime-EEEE5555.js', + 'assets/shared-DDDD4444.js', + ], + visibility: 'debug', + }, + }) + expect(catalog.cases[0].code).toEqual({ + tanstack: 'benchmarks/conformance/cases/01-line/tanstack.ts', + reference: 'benchmarks/conformance/cases/01-line/plot.ts', + }) + expect(summary).toMatchObject({ + assetCount: 4, + caseCount: 1, + referenceCounts: { + 'observable-plot': 1, + recharts: 0, + echarts: 0, + }, + }) + }) + + it('hashes emitted bytes deterministically', async () => { + const left = await createArtifact() + const right = await createArtifact() + + expect(left.catalog.assets).toEqual(right.catalog.assets) + expect(left.catalog.assets['assets/tanstack-AAAA1111.js']).toEqual({ + bytes: 8, + sha256: + '8b0580d3326507f3298f05caff42ea5511092530141a871b84a28ffaa3365475', + imports: ['assets/shared-DDDD4444.js'], + dynamicImports: [], + }) + }) + + it('keeps a cyclic root out of its own preload closure', async () => { + const cyclicManifest = structuredClone(viteManifest) + cyclicManifest['_shared.js'].imports = [ + '../../benchmarks/conformance/cases/01-line/tanstack.ts', + ] + const artifact = await createCatalogArtifact({ + cases, + revision, + viteManifest: cyclicManifest, + readAsset: async (assetPath) => contents.get(assetPath), + }) + const catalog = attachEmbedContract(artifact.catalog, { + protocol: { version: 1 }, + }) + + expect(catalog.cases[0].modules.tanstack.preload).toEqual([ + 'assets/shared-DDDD4444.js', + ]) + expect(() => validateCatalogArtifactManifest(catalog)).not.toThrow() + }) + + it('rejects assets outside a safe relative module path', async () => { + const invalidManifest = structuredClone(viteManifest) + invalidManifest['_shared.js'].file = '../shared.js' + + await expect( + createCatalogArtifact({ + cases, + revision, + viteManifest: invalidManifest, + readAsset: async (assetPath) => contents.get(assetPath), + }), + ).rejects.toThrow('invalid catalog asset path') + }) + + it('rejects module names without a content hash', async () => { + const invalidManifest = structuredClone(viteManifest) + invalidManifest['_shared.js'].file = 'assets/shared.js' + + await expect( + createCatalogArtifact({ + cases, + revision, + viteManifest: invalidManifest, + readAsset: async (assetPath) => contents.get(assetPath), + }), + ).rejects.toThrow('invalid catalog asset path') + }) + + it('rejects unreferenced files and non-debug comparisons', async () => { + const artifact = await createArtifact() + const catalog = attachEmbedContract(artifact.catalog, { + protocol: { version: 1 }, + }) + catalog.assets['assets/extra-GGGG7777.js'] = { + bytes: 1, + sha256: 'b'.repeat(64), + imports: [], + dynamicImports: [], + } + catalog.cases[0].modules.comparison.visibility = 'public' + + expect(() => validateCatalogArtifactManifest(catalog)).toThrow( + 'comparison must be debug-only', + ) + }) +}) + +function createArtifact() { + return createCatalogArtifact({ + cases, + revision, + viteManifest, + readAsset: async (assetPath) => contents.get(assetPath), + }) +} + +function bytes(value) { + return new TextEncoder().encode(value) +} diff --git a/scripts/catalog-deployment.test.mjs b/scripts/catalog-deployment.test.mjs deleted file mode 100644 index af63a329..00000000 --- a/scripts/catalog-deployment.test.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - catalogBasePath, - catalogOrigin, - stageConformanceDeployment, -} from './stage-conformance-deployment.mjs' - -const temporaryDirectories = [] - -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => fs.rm(directory, { recursive: true, force: true })), - ) -}) - -describe('catalog deployment staging', () => { - it('mirrors the production URL below the Worker asset root', async () => { - const fixture = await createFixture() - const result = await stageConformanceDeployment(fixture) - - await expect( - fs.readFile( - path.join(result.stageDirectory, 'charts', 'catalog', 'index.html'), - 'utf8', - ), - ).resolves.toBe('catalog') - await expect( - fs.readFile(path.join(result.stageDirectory, '_headers'), 'utf8'), - ).resolves.toBe('headers') - expect(result.caseCount).toBe(1) - expect(result.fileCount).toBe(4) - }) - - it('rejects a build for another public base path', async () => { - const fixture = await createFixture({ basePath: '/' }) - - await expect(stageConformanceDeployment(fixture)).rejects.toThrow( - `catalog base path must be ${catalogBasePath}`, - ) - }) - - it('refuses to replace another repository directory', async () => { - const fixture = await createFixture() - fixture.stageDirectory = path.join(process.cwd(), 'packages') - - await expect(stageConformanceDeployment(fixture)).rejects.toThrow( - 'catalog staging is limited to .catalog-deploy or a temporary directory', - ) - }) -}) - -async function createFixture({ basePath = catalogBasePath } = {}) { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), 'tanstack-charts-catalog-'), - ) - temporaryDirectories.push(directory) - const sourceDirectory = path.join(directory, 'source') - const stageDirectory = path.join(directory, 'stage') - const headersPath = path.join(directory, '_headers') - await fs.mkdir(sourceDirectory, { recursive: true }) - await fs.writeFile(path.join(sourceDirectory, 'index.html'), 'catalog') - await fs.writeFile(path.join(sourceDirectory, '404.html'), 'missing') - await fs.writeFile( - path.join(sourceDirectory, 'catalog.json'), - JSON.stringify({ - site: { - basePath, - origin: catalogOrigin, - }, - cases: [{ id: '01-line' }], - }), - ) - await fs.writeFile(headersPath, 'headers') - return { headersPath, sourceDirectory, stageDirectory } -} diff --git a/scripts/check-catalog-deployment.mjs b/scripts/check-catalog-deployment.mjs deleted file mode 100644 index 3020ec7b..00000000 --- a/scripts/check-catalog-deployment.mjs +++ /dev/null @@ -1,191 +0,0 @@ -import { fileURLToPath } from 'node:url' -import path from 'node:path' - -export const productionCatalogUrl = 'https://tanstack.com/charts/catalog/' - -export async function checkCatalogDeployment({ - catalogUrl = productionCatalogUrl, - fetchImplementation = fetch, - minimumCaseCount = 100, -} = {}) { - const baseUrl = new URL(catalogUrl) - assert( - baseUrl.pathname.endsWith('/'), - `catalog URL must end in a slash: ${baseUrl}`, - ) - - const bareUrl = new URL(baseUrl) - bareUrl.pathname = bareUrl.pathname.slice(0, -1) - await assertCanonicalRedirect(fetchImplementation, bareUrl, baseUrl) - - const queriedBareUrl = new URL(bareUrl) - queriedBareUrl.searchParams.set('deployment-smoke', '1') - await assertCanonicalRedirect(fetchImplementation, queriedBareUrl, baseUrl) - - const root = await fetchOk(fetchImplementation, baseUrl) - const rootHtml = await root.text() - assertSecurityHeaders(root, { frameable: false }) - assert( - rootHtml.includes( - `= minimumCaseCount, - `deployed catalog has fewer than ${minimumCaseCount} cases`, - ) - - const sample = catalog.cases[0] - assert( - typeof sample?.routes?.page === 'string' && - typeof sample?.routes?.embed === 'string', - 'catalog sample routes are missing', - ) - - const detailUrl = new URL(sample.routes.page, baseUrl.origin) - const detail = await fetchOk(fetchImplementation, detailUrl) - assertSecurityHeaders(detail, { frameable: false }) - assert( - (await detail.text()).includes( - ` setTimeout(resolve, 3_000)) - } - } - } - throw lastError -} - -const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' -if (invokedPath === fileURLToPath(import.meta.url)) { - const result = await checkWithRetries() - console.log( - `Verified ${result.caseCount} live catalog cases, sample ${result.sampleId}, and ${result.assetPath}.`, - ) -} diff --git a/scripts/check-catalog-loading.mjs b/scripts/check-catalog-loading.mjs index 9f20465e..8e18983a 100644 --- a/scripts/check-catalog-loading.mjs +++ b/scripts/check-catalog-loading.mjs @@ -1,84 +1,27 @@ +import { promises as fs } from 'node:fs' import { gzipSync } from 'node:zlib' -import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import { build } from 'vite' -import { launchBenchmarkBrowser } from './benchmark/browser.mjs' -import { attachPageErrorCollector } from './benchmark/page-errors.mjs' +import { validateCatalogArtifactManifest } from './catalog-artifact.mjs' const rootDirectory = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ) const catalogDirectory = path.join(rootDirectory, 'examples', 'conformance') +const artifactDirectory = path.join(rootDirectory, '.catalog-artifact') const catalogBasePath = '/charts/catalog/' -const comparisonQuery = 'compare=1' -const scenarios = [ - { - id: '01-line-gaps', - renderer: 'observable-plot', - sourceFile: 'plot.ts', - }, - { - id: '76-pie', - renderer: 'recharts', - sourceFile: 'recharts.ts', - }, - { - id: '106-polar-line', - renderer: 'echarts', - sourceFile: 'echarts.ts', - }, -] const graph = await inspectCatalogGraph() -const suppliedUrl = readOption('--url') -let worker -let catalogUrl - -if (suppliedUrl) { - catalogUrl = normalizeCatalogUrl(suppliedUrl) -} else { - process.env.WRANGLER_LOG_PATH ??= path.join( - os.tmpdir(), - 'tanstack-charts-catalog-runtime.log', - ) - const { unstable_startWorker } = await import('wrangler') - worker = await unstable_startWorker({ - config: 'wrangler.catalog.jsonc', - dev: { - inspector: { - hostname: '127.0.0.1', - port: 0, - }, - persist: false, - server: { - hostname: '127.0.0.1', - port: 0, - secure: false, - }, - }, - }) - await worker.ready - catalogUrl = new URL(catalogBasePath.slice(1), await worker.url) -} - -const browser = await launchBenchmarkBrowser() - -try { - for (const scenario of scenarios) { - await checkDetail(browser, catalogUrl, graph, scenario, false) - await checkDetail(browser, catalogUrl, graph, scenario, true) - } - await checkComparisonNavigation(browser, catalogUrl) - await checkEmbedIsolation(browser, catalogUrl, graph, scenarios[1]) -} finally { - await browser.close() - await worker?.dispose() -} +const artifact = JSON.parse( + await fs.readFile(path.join(artifactDirectory, 'catalog.json'), 'utf8'), +) +const artifactSummary = validateCatalogArtifactManifest(artifact) +verifyPublishedGraph(graph, artifact) console.log( - `Catalog loading passed: ${formatBytes(graph.entryBytes)} entry (${formatBytes(graph.entryGzipBytes)} gzip), 100 native implementations, and comparison chunks requested only with ?${comparisonQuery}.`, + `Catalog graph passed: ${formatBytes(graph.entryBytes)} local authoring entry (${formatBytes(graph.entryGzipBytes)} gzip); ${artifactSummary.assetCount} published implementation modules (${formatBytes(artifactSummary.assetBytes)}) with comparisons remaining debug-only.`, ) async function inspectCatalogGraph() { @@ -87,6 +30,7 @@ async function inspectCatalogGraph() { base: catalogBasePath, logLevel: 'silent', build: { + manifest: true, target: 'es2022', write: false, }, @@ -123,11 +67,11 @@ async function inspectCatalogGraph() { '/benchmarks/conformance/comparison-catalog.ts', ), ), - 'the initial static graph includes comparison code', + 'the local authoring entry static graph includes comparison code', ) assert( !entry.code.includes('tanstack.test.ts'), - 'the initial entry registers a test module', + 'the local authoring entry registers a test module', ) const caseEntries = chunks.filter( @@ -169,154 +113,69 @@ async function inspectCatalogGraph() { chunks, chunksByFile, comparisonCatalog, + entry, entryBytes: Buffer.byteLength(entry.code), entryGzipBytes: gzipSync(entry.code).byteLength, } } -async function checkDetail(browser, baseUrl, graph, scenario, comparisonMode) { - const context = await browser.newContext({ serviceWorkers: 'block' }) - const page = await context.newPage() - const errors = attachPageErrorCollector(page) - const requests = collectRequests(page, baseUrl) - await disableCache(context, page) - - try { - const url = new URL(`charts/${scenario.id}/`, baseUrl) - if (comparisonMode) url.search = comparisonQuery - await page.goto(url.href, { waitUntil: 'domcontentloaded' }) - await waitForRenderers(page, comparisonMode ? 2 : 1) +function verifyPublishedGraph(graph, artifact) { + const publishedFiles = new Set(Object.keys(artifact.assets)) + assert( + !publishedFiles.has(graph.entry.fileName), + 'published artifact contains the standalone authoring entry', + ) + assert( + !publishedFiles.has(graph.comparisonCatalog.fileName), + 'published artifact contains the comparison registry', + ) - const renderers = await page - .locator('.renderer') - .evaluateAll((nodes) => nodes.map((node) => node.dataset.renderer)) - const expectedRenderers = comparisonMode - ? [scenario.renderer, 'tanstack'] - : ['tanstack'] + for (const assetPath of publishedFiles) { + const chunk = graph.chunksByFile.get(assetPath) + assert(chunk, `published artifact contains unknown chunk ${assetPath}`) assert( - JSON.stringify(renderers) === JSON.stringify(expectedRenderers), - `${url.href} rendered ${JSON.stringify(renderers)}`, + !normalizePath(chunk.facadeModuleId ?? '').endsWith('?raw'), + `published artifact contains raw-source wrapper ${assetPath}`, ) + } - const referenceChunk = chunkByFacade( - graph.chunks, - `/benchmarks/conformance/cases/${scenario.id}/${scenario.sourceFile}`, - ) - const sourceChunk = chunkByFacade( - graph.chunks, - `/benchmarks/conformance/cases/${scenario.id}/${scenario.sourceFile}?raw`, - ) - const competitorChunks = staticClosure( - referenceChunk.fileName, + for (const entry of artifact.cases) { + const tanstackClosureModules = staticClosure( + entry.modules.tanstack.path, graph.chunksByFile, - ).filter((chunk) => - Object.keys(chunk.modules).some((module) => - isRendererPackage(module, scenario.renderer), + ).flatMap((chunk) => Object.keys(chunk.modules)) + assert( + !tanstackClosureModules.some( + (module) => + isReferenceImplementation(module) || isCompetitorPackage(module), ), + `${entry.id} TanStack module closure includes comparison code`, + ) + + const expectedTanstack = chunkByFacade( + graph.chunks, + `/benchmarks/conformance/cases/${entry.id}/tanstack.ts`, ) assert( - competitorChunks.length > 0, - `${scenario.renderer} has no identifiable package chunk`, + entry.modules.tanstack.path === expectedTanstack.fileName, + `${entry.id} TanStack module drifted from the Vite graph`, ) - const gatedFiles = [ - graph.comparisonCatalog.fileName, - referenceChunk.fileName, - sourceChunk.fileName, - ...competitorChunks.map((chunk) => chunk.fileName), - ] - if (comparisonMode) { - for (const file of gatedFiles) { - assert( - requests.has(assetPath(baseUrl, file)), - `${url.href} did not request ${file}`, - ) - } - } else { - for (const file of gatedFiles) { - assert( - !requests.has(assetPath(baseUrl, file)), - `${url.href} unexpectedly requested ${file}`, - ) - } - } - errors.assertNone() - } finally { - await context.close() - } -} - -async function checkComparisonNavigation(browser, baseUrl) { - const context = await browser.newContext({ serviceWorkers: 'block' }) - const page = await context.newPage() - const errors = attachPageErrorCollector(page) - await disableCache(context, page) - - try { - const url = new URL('charts/01-line-gaps/', baseUrl) - url.search = comparisonQuery - await page.goto(url.href, { waitUntil: 'domcontentloaded' }) - await waitForRenderers(page, 2) - await page.getByRole('link', { name: 'Next →' }).click() - await page.waitForURL((nextUrl) => nextUrl.search === `?${comparisonQuery}`) - await waitForRenderers(page, 2) - errors.assertNone() - } finally { - await context.close() - } -} - -async function checkEmbedIsolation(browser, baseUrl, graph, scenario) { - const context = await browser.newContext({ serviceWorkers: 'block' }) - const page = await context.newPage() - const errors = attachPageErrorCollector(page) - const requests = collectRequests(page, baseUrl) - await disableCache(context, page) - - try { - const url = new URL(`embed/${scenario.id}/`, baseUrl) - url.search = comparisonQuery - await page.goto(url.href, { waitUntil: 'domcontentloaded' }) - await page.locator('.embed-chart > *').first().waitFor() - assert( - (await page.locator('.renderer').count()) === 0, - 'embed rendered catalog comparison panels', + const referenceFile = + entry.modules.comparison.renderer === 'observable-plot' + ? 'plot' + : entry.modules.comparison.renderer + const expectedReference = chunkByFacade( + graph.chunks, + `/benchmarks/conformance/cases/${entry.id}/${referenceFile}.ts`, ) assert( - !requests.has(assetPath(baseUrl, graph.comparisonCatalog.fileName)), - 'embed requested the comparison catalog', + entry.modules.comparison.path === expectedReference.fileName, + `${entry.id} comparison module drifted from the Vite graph`, ) - errors.assertNone() - } finally { - await context.close() } } -function waitForRenderers(page, count) { - return page.waitForFunction((expectedCount) => { - const outputs = [...document.querySelectorAll('.renderer output')] - return ( - outputs.length === expectedCount && - outputs.every((output) => output.textContent !== 'pending') - ) - }, count) -} - -function collectRequests(page, baseUrl) { - const requests = new Set() - page.on('request', (request) => { - const url = new URL(request.url()) - if (url.origin === baseUrl.origin) requests.add(url.pathname) - }) - return requests -} - -async function disableCache(context, page) { - const session = await context.newCDPSession(page) - await session.send('Network.enable') - await session.send('Network.setCacheDisabled', { cacheDisabled: true }) -} - function staticClosure(fileName, chunksByFile) { const visited = new Set() const chunks = [] @@ -400,33 +259,12 @@ function isRendererPackage(module, renderer) { ) } -function assetPath(baseUrl, fileName) { - return `${baseUrl.pathname}${fileName}` -} - -function normalizeCatalogUrl(value) { - const url = new URL(value) - assert( - url.pathname.endsWith('/'), - `catalog URL must end in a slash: ${url.href}`, - ) - return url -} - function normalizePath(value) { return value.replaceAll('\\', '/') } -function readOption(name) { - const index = process.argv.indexOf(name) - if (index < 0) return '' - const value = process.argv[index + 1] - assert(value && !value.startsWith('--'), `${name} requires a value`) - return value -} - -function formatBytes(value) { - return `${(value / 1024).toFixed(1)} KiB` +function formatBytes(bytes) { + return `${(bytes / 1024).toFixed(1)} KiB` } function assert(condition, message) { diff --git a/scripts/check-conformance-artifact.mjs b/scripts/check-conformance-artifact.mjs new file mode 100644 index 00000000..dc2ab506 --- /dev/null +++ b/scripts/check-conformance-artifact.mjs @@ -0,0 +1,111 @@ +import { createHash } from 'node:crypto' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + catalogArtifactFileSizeLimit, + validateCatalogArtifactManifest, +} from './catalog-artifact.mjs' + +const rootDirectory = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +) +const artifactDirectory = path.join(rootDirectory, '.catalog-artifact') +const catalogPath = path.join(artifactDirectory, 'catalog.json') +const catalogSource = await fs.readFile(catalogPath, 'utf8') + +if (Buffer.byteLength(catalogSource) > catalogArtifactFileSizeLimit) { + throw new Error('catalog.json exceeds the artifact file-size limit') +} + +const catalog = JSON.parse(catalogSource) +const summary = validateCatalogArtifactManifest(catalog) +assert( + summary.caseCount === 100, + `expected 100 cases, got ${summary.caseCount}`, +) +assert( + summary.referenceCounts['observable-plot'] === 68 && + summary.referenceCounts.recharts === 21 && + summary.referenceCounts.echarts === 11, + `unexpected comparison counts ${JSON.stringify(summary.referenceCounts)}`, +) + +const expectedFiles = new Set(['catalog.json', ...Object.keys(catalog.assets)]) +const actualFiles = new Set(await listArtifactFiles(artifactDirectory)) +assertSetEqual(actualFiles, expectedFiles, 'catalog artifact files') + +for (const [assetPath, expected] of Object.entries(catalog.assets)) { + const content = await fs.readFile( + path.join(artifactDirectory, ...assetPath.split('/')), + ) + assert( + content.byteLength === expected.bytes, + `${assetPath} has ${content.byteLength} bytes, expected ${expected.bytes}`, + ) + assert( + createHash('sha256').update(content).digest('hex') === expected.sha256, + `${assetPath} does not match its sha256`, + ) +} + +for (const entry of catalog.cases) { + for (const sourcePath of Object.values(entry.code)) { + const sourceFile = path.join(rootDirectory, ...sourcePath.split('/')) + const stats = await fs.stat(sourceFile) + assert(stats.isFile(), `${sourcePath} is not a source file`) + } +} + +console.log( + `Verified schema v2 catalog artifact: ${summary.caseCount} cases, ${summary.assetCount} allowlisted modules, ${formatBytes(summary.assetBytes)}, revision ${catalog.revision}.`, +) + +async function listArtifactFiles(directory) { + const files = [] + + async function visit(currentDirectory) { + for (const entry of await fs.readdir(currentDirectory, { + withFileTypes: true, + })) { + const entryPath = path.join(currentDirectory, entry.name) + const relativePath = path + .relative(directory, entryPath) + .split(path.sep) + .join('/') + + if (entry.isSymbolicLink()) { + throw new Error(`catalog artifact contains symlink ${relativePath}`) + } + if (entry.isDirectory()) { + await visit(entryPath) + continue + } + if (!entry.isFile()) { + throw new Error(`catalog artifact contains non-file ${relativePath}`) + } + files.push(relativePath) + } + } + + await visit(directory) + return files.sort() +} + +function assertSetEqual(actual, expected, label) { + const missing = [...expected].filter((entry) => !actual.has(entry)) + const extra = [...actual].filter((entry) => !expected.has(entry)) + assert( + missing.length === 0 && extra.length === 0, + `${label} mismatch; missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'}`, + ) +} + +function formatBytes(bytes) { + return `${(bytes / 1024 / 1024).toFixed(2)} MiB` +} + +function assert(condition, message) { + if (!condition) throw new Error(message) +} diff --git a/scripts/check-conformance-site.mjs b/scripts/check-conformance-site.mjs deleted file mode 100644 index 2700c29c..00000000 --- a/scripts/check-conformance-site.mjs +++ /dev/null @@ -1,132 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { chartEmbedContract } from '../examples/conformance/src/embed-contract.ts' - -const rootDirectory = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -) -const outputDirectory = path.join( - rootDirectory, - 'examples', - 'conformance', - 'dist', -) -const basePath = normalizeBasePath(process.env.CATALOG_BASE_PATH ?? '/') -const publicOrigin = normalizeOrigin(process.env.CATALOG_ORIGIN) - -const catalog = JSON.parse( - await fs.readFile(path.join(outputDirectory, 'catalog.json'), 'utf8'), -) - -assert(catalog.schemaVersion === 1, 'catalog.json schemaVersion must be 1') -assert( - catalog.site?.origin === (publicOrigin || null), - 'catalog.json origin does not match CATALOG_ORIGIN', -) -assert( - catalog.site?.basePath === basePath, - 'catalog.json basePath does not match CATALOG_BASE_PATH', -) -assert( - JSON.stringify(catalog.embed) === JSON.stringify(chartEmbedContract), - 'catalog.json embed contract drifted from the runtime contract', -) -assert( - Array.isArray(catalog.cases) && catalog.cases.length > 0, - 'catalog.json must contain cases', -) - -await verifyRoute(basePath, 'index,follow') -await verifyRoute(`${basePath}all/`, 'index,follow') - -for (const entry of catalog.cases) { - assert( - typeof entry.id === 'string' && entry.id.length > 0, - 'every catalog case needs an id', - ) - assert( - entry.routes?.page === `${basePath}charts/${encodeURIComponent(entry.id)}/`, - `invalid page route for ${entry.id}`, - ) - assert( - entry.routes?.embed === `${basePath}embed/${encodeURIComponent(entry.id)}/`, - `invalid embed route for ${entry.id}`, - ) - await verifyRoute(entry.routes.page, 'index,follow') - await verifyRoute(entry.routes.embed, 'noindex,follow') -} - -const notFound = await fs.readFile( - path.join(outputDirectory, '404.html'), - 'utf8', -) -assert( - hasMetaContent(notFound, 'robots', 'noindex,follow'), - '404.html must be noindex,follow', -) - -const shell = await fs.readFile( - path.join(outputDirectory, 'index.html'), - 'utf8', -) -if (basePath !== '/') { - assert( - shell.includes(`${basePath}assets/`), - `built assets must use deployment base ${basePath}`, - ) -} - -console.log( - `Verified ${catalog.cases.length} catalog pages and ${catalog.cases.length} direct embeds at ${basePath}.`, -) - -async function verifyRoute(route, robots) { - assert( - route.startsWith(basePath) && route.endsWith('/'), - `route must stay below ${basePath} and end in a slash: ${route}`, - ) - const relativeRoute = route.slice(basePath.length) - const filePath = relativeRoute - ? path.join(outputDirectory, relativeRoute, 'index.html') - : path.join(outputDirectory, 'index.html') - const html = await fs.readFile(filePath, 'utf8') - const canonical = publicOrigin ? `${publicOrigin}${route}` : route - assert( - html.includes( - ``, - ), - `canonical URL mismatch in ${filePath}`, - ) - assert( - hasMetaContent(html, 'robots', robots), - `robots metadata mismatch in ${filePath}`, - ) -} - -function hasMetaContent(html, name, content) { - return html.includes(``) -} - -function assert(condition, message) { - if (!condition) throw new Error(message) -} - -function normalizeBasePath(value) { - const leadingSlash = value.startsWith('/') ? value : `/${value}` - return leadingSlash.endsWith('/') ? leadingSlash : `${leadingSlash}/` -} - -function normalizeOrigin(value) { - if (!value) return '' - return new URL(value).origin -} - -function escapeAttribute(value) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/scripts/check-local-catalog-worker.mjs b/scripts/check-local-catalog-worker.mjs deleted file mode 100644 index c7670b18..00000000 --- a/scripts/check-local-catalog-worker.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { unstable_startWorker } from 'wrangler' -import { checkCatalogDeployment } from './check-catalog-deployment.mjs' - -const worker = await unstable_startWorker({ - config: 'wrangler.catalog.jsonc', - dev: { - inspector: { - hostname: '127.0.0.1', - port: 0, - }, - persist: false, - server: { - hostname: '127.0.0.1', - port: 0, - secure: false, - }, - }, -}) - -try { - await worker.ready - const result = await checkCatalogDeployment({ - fetchImplementation: worker.fetch, - }) - console.log( - `Verified local Worker with ${result.caseCount} cases and sample ${result.sampleId}.`, - ) -} finally { - await worker.dispose() -} diff --git a/scripts/stage-conformance-deployment.mjs b/scripts/stage-conformance-deployment.mjs deleted file mode 100644 index c4b4aecd..00000000 --- a/scripts/stage-conformance-deployment.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const rootDirectory = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -) - -export const catalogBasePath = '/charts/catalog/' -export const catalogOrigin = 'https://tanstack.com' -export const workerAssetFileLimit = 20_000 -export const workerAssetSizeLimit = 25 * 1024 * 1024 - -const defaultSourceDirectory = path.join( - rootDirectory, - 'examples', - 'conformance', - 'dist', -) -const defaultStageDirectory = path.join(rootDirectory, '.catalog-deploy') -const defaultHeadersPath = path.join( - rootDirectory, - 'deploy', - 'catalog', - '_headers', -) - -export async function stageConformanceDeployment({ - sourceDirectory = defaultSourceDirectory, - stageDirectory = defaultStageDirectory, - headersPath = defaultHeadersPath, -} = {}) { - const resolvedSource = path.resolve(sourceDirectory) - const resolvedStage = path.resolve(stageDirectory) - assertSafeStageDirectory(resolvedStage) - - const catalog = JSON.parse( - await fs.readFile(path.join(resolvedSource, 'catalog.json'), 'utf8'), - ) - assert( - catalog.site?.origin === catalogOrigin, - `catalog origin must be ${catalogOrigin}`, - ) - assert( - catalog.site?.basePath === catalogBasePath, - `catalog base path must be ${catalogBasePath}`, - ) - assert( - Array.isArray(catalog.cases) && catalog.cases.length > 0, - 'catalog must contain at least one case', - ) - - const sourceSummary = await inspectAssetTree(resolvedSource) - assertWorkerAssetLimits(sourceSummary, 1) - - await fs.rm(resolvedStage, { recursive: true, force: true }) - const publicDirectory = path.join( - resolvedStage, - ...catalogBasePath.split('/').filter(Boolean), - ) - await fs.mkdir(path.dirname(publicDirectory), { recursive: true }) - await fs.cp(resolvedSource, publicDirectory, { - recursive: true, - errorOnExist: true, - }) - await fs.copyFile(headersPath, path.join(resolvedStage, '_headers')) - - const stagedSummary = await inspectAssetTree(resolvedStage) - assertWorkerAssetLimits(stagedSummary) - - return { - caseCount: catalog.cases.length, - fileCount: stagedSummary.fileCount, - largestFileBytes: stagedSummary.largestFileBytes, - totalBytes: stagedSummary.totalBytes, - publicDirectory, - stageDirectory: resolvedStage, - } -} - -async function inspectAssetTree(directory) { - let fileCount = 0 - let largestFileBytes = 0 - let totalBytes = 0 - - async function visit(currentDirectory) { - const entries = await fs.readdir(currentDirectory, { - withFileTypes: true, - }) - - for (const entry of entries) { - const entryPath = path.join(currentDirectory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error( - `catalog deployment cannot contain symlinks: ${entryPath}`, - ) - } - if (entry.isDirectory()) { - await visit(entryPath) - continue - } - if (!entry.isFile()) { - throw new Error(`catalog deployment contains a non-file: ${entryPath}`) - } - - const stats = await fs.stat(entryPath) - fileCount += 1 - largestFileBytes = Math.max(largestFileBytes, stats.size) - totalBytes += stats.size - } - } - - await visit(directory) - return { fileCount, largestFileBytes, totalBytes } -} - -function assertWorkerAssetLimits(summary, additionalFiles = 0) { - assert( - summary.fileCount + additionalFiles <= workerAssetFileLimit, - `catalog has ${summary.fileCount + additionalFiles} assets; Workers allows ${workerAssetFileLimit}`, - ) - assert( - summary.largestFileBytes <= workerAssetSizeLimit, - `catalog has a ${summary.largestFileBytes}-byte asset; Workers allows ${workerAssetSizeLimit}`, - ) -} - -function assertSafeStageDirectory(directory) { - const root = path.parse(directory).root - assert(directory !== root, 'refusing to stage at a filesystem root') - assert(directory !== rootDirectory, 'refusing to replace the repository root') - assert( - directory === defaultStageDirectory || - isDescendantDirectory(path.resolve(os.tmpdir()), directory), - 'catalog staging is limited to .catalog-deploy or a temporary directory', - ) -} - -function isDescendantDirectory(parent, directory) { - const relative = path.relative(parent, directory) - return ( - relative !== '' && - relative !== '..' && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative) - ) -} - -function assert(condition, message) { - if (!condition) throw new Error(message) -} - -const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '' -if (invokedPath === fileURLToPath(import.meta.url)) { - const summary = await stageConformanceDeployment() - console.log( - `Staged ${summary.caseCount} catalog cases in ${summary.fileCount} files (${formatBytes(summary.totalBytes)}; largest ${formatBytes(summary.largestFileBytes)}).`, - ) -} - -function formatBytes(bytes) { - return `${(bytes / 1024 / 1024).toFixed(2)} MiB` -} diff --git a/wrangler.catalog.jsonc b/wrangler.catalog.jsonc deleted file mode 100644 index f03412f6..00000000 --- a/wrangler.catalog.jsonc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "tanstack-charts-catalog", - "account_id": "8da95258a9c70b54c3e2b374a0079106", - "compatibility_date": "2026-06-24", - "workers_dev": false, - "preview_urls": true, - "routes": [ - { - "pattern": "tanstack.com/charts/catalog*", - "zone_name": "tanstack.com", - }, - ], - "assets": { - "directory": ".catalog-deploy", - "html_handling": "auto-trailing-slash", - "not_found_handling": "404-page", - }, -} From b7d03ad8668b95e95cb1b13a6c377e20a7e12f9b Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 22:56:35 -0600 Subject: [PATCH 2/5] Pin catalog workflow actions --- .../workflows/chart-library-benchmarks.yml | 28 +++++++++---------- API-FRICTION.md | 4 ++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index e40b8517..b38e7009 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -22,9 +22,9 @@ jobs: timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -44,7 +44,7 @@ jobs: - name: Upload production catalog if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: charts-catalog-${{ github.sha }} path: .catalog-artifact @@ -57,9 +57,9 @@ jobs: timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -72,7 +72,7 @@ jobs: - name: Upload comparison if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: chart-library-comparison-${{ github.run_id }} path: .benchmark-output/results @@ -84,9 +84,9 @@ jobs: timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -106,7 +106,7 @@ jobs: - name: Upload conformance if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: chart-library-conformance-${{ github.run_id }} path: | @@ -120,9 +120,9 @@ jobs: timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -147,7 +147,7 @@ jobs: - name: Upload stress results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: chart-library-stress-${{ github.run_id }} path: .benchmark-output/stress/results @@ -164,12 +164,12 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - name: Download validated catalog - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: charts-catalog-${{ github.sha }} path: .catalog-artifact diff --git a/API-FRICTION.md b/API-FRICTION.md index 42c8c8ba..70b42d71 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -2629,7 +2629,9 @@ Each entry records: unreferenced assets, and public comparison modules. The loading gate checks every TanStack root's static closure for reference cases or competitor packages. Main-branch CI uploads the validated artifact and publishes only - `catalog.json` and `assets/*.js` to `catalog-dist`. + `catalog.json` and `assets/*.js` to `catalog-dist`. The publication workflow + pins every third-party action to a full commit SHA, as required by the + repository's Actions policy. - Follow-up: keep monitoring through the TanStack.com cutover, production route verification, and retirement of the previously deployed catalog Worker. From bf30f6e0d4c8bb7895f72c2dcfcfe48185cb3346 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 23:07:38 -0600 Subject: [PATCH 3/5] Enable Corepack before workflow caching --- .github/workflows/chart-library-benchmarks.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index b38e7009..b920111c 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -24,12 +24,13 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: corepack enable + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm - - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm format:check - run: pnpm docs:check @@ -59,12 +60,13 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: corepack enable + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm - - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps chromium - run: pnpm benchmark -- --profile=ci @@ -86,12 +88,13 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: corepack enable + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm - - run: corepack enable - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps chromium - run: pnpm catalog:build @@ -122,12 +125,13 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: corepack enable + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm - - run: corepack enable - run: pnpm install --frozen-lockfile - name: Validate stress workload invariants run: pnpm exec vitest run benchmarks/comparison/stress From 823d3c1bcb5b783bb0f4658116ad6c8084b9f6b6 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 23:23:11 -0600 Subject: [PATCH 4/5] Pin canonical bundle measurement environment --- .github/workflows/chart-library-benchmarks.yml | 18 +++++++++--------- API-FRICTION.md | 6 +++++- benchmarks/bundle-size/universal-baseline.json | 16 ++++++++-------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index b920111c..15c40ab1 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -18,7 +18,7 @@ concurrency: jobs: validate: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 24 + node-version: 24.18.0 cache: pnpm - run: pnpm install --frozen-lockfile @@ -54,7 +54,7 @@ jobs: compare: needs: validate - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: @@ -64,7 +64,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 24 + node-version: 24.18.0 cache: pnpm - run: pnpm install --frozen-lockfile @@ -82,7 +82,7 @@ jobs: conformance: needs: validate - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -92,7 +92,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 24 + node-version: 24.18.0 cache: pnpm - run: pnpm install --frozen-lockfile @@ -119,7 +119,7 @@ jobs: stress: needs: validate - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -129,7 +129,7 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: 24 + node-version: 24.18.0 cache: pnpm - run: pnpm install --frozen-lockfile @@ -162,7 +162,7 @@ jobs: needs: - validate - conformance - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: write diff --git a/API-FRICTION.md b/API-FRICTION.md index 70b42d71..f88a803a 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -938,7 +938,11 @@ Each entry records: locked minified entry by 133–250 bytes; gzip fell by 14–29 bytes for static scenes and rose by 43 bytes for the DOM host, 35 bytes for the React adapter, and 21 bytes for the React line consumer. Those exact results are - now locked rather than hidden inside unused ceiling headroom. + now locked rather than hidden inside unused ceiling headroom. The canonical + byte lock runs on pinned Ubuntu 24.04 and Node 24.18.0; this prevents runner + and compressor upgrades from masquerading as library-size changes. The + current canonical baseline records the unchanged source tree under that + environment. ### F-041 — Bounded segments and caps required custom marks diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 062d951e..ab9cb728 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -4,27 +4,27 @@ "bundles": { "D3-scale line scene": { "bytes": 31200, - "gzip": 12492 + "gzip": 12493 }, "D3-scale line + static SVG": { "bytes": 34252, - "gzip": 13624 + "gzip": 13622 }, "Representative marks": { "bytes": 42562, - "gzip": 15889 + "gzip": 15916 }, "TanStack DOM host": { "bytes": 28362, "gzip": 10896 }, "React adapter": { - "bytes": 30011, - "gzip": 11437 + "bytes": 30364, + "gzip": 11567 }, "React line consumer": { - "bytes": 51559, - "gzip": 19955 + "bytes": 51920, + "gzip": 20100 }, "Custom-scale line scene": { "bytes": 11974, @@ -32,7 +32,7 @@ }, "D3 linear-scale line scene": { "bytes": 31132, - "gzip": 12458 + "gzip": 12459 } } } From d896e865f1afbdd55f5f946c0d6a617897304f7f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 28 Jul 2026 23:52:12 -0600 Subject: [PATCH 5/5] Harden catalog publication and validation --- .../workflows/chart-library-benchmarks.yml | 66 ++++++++- API-FRICTION.md | 11 +- benchmarks/comparison/README.md | 6 +- benchmarks/comparison/bundle-baseline.json | 122 +++++++-------- examples/conformance/vite.config.ts | 51 ++++++- scripts/build-conformance-artifact.mjs | 29 +--- scripts/catalog-artifact.mjs | 10 +- scripts/catalog-artifact.test.mjs | 13 +- scripts/check-catalog-loading.mjs | 140 +++++++++++++----- scripts/check-conformance-artifact.mjs | 14 +- scripts/compare-chart-libraries.mjs | 11 +- 11 files changed, 333 insertions(+), 140 deletions(-) diff --git a/.github/workflows/chart-library-benchmarks.yml b/.github/workflows/chart-library-benchmarks.yml index 15c40ab1..caa747ea 100644 --- a/.github/workflows/chart-library-benchmarks.yml +++ b/.github/workflows/chart-library-benchmarks.yml @@ -8,12 +8,18 @@ on: schedule: - cron: '17 7 * * 1' workflow_dispatch: + inputs: + upload_bundle_baseline_candidate: + description: Upload exact comparison bundle measurements + required: false + type: boolean + default: false permissions: contents: read concurrency: - group: chart-library-benchmarks-${{ github.ref }} + group: chart-library-benchmarks-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true jobs: @@ -23,6 +29,8 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: corepack enable @@ -38,9 +46,21 @@ jobs: - run: pnpm test - run: pnpm package:check - run: pnpm bundle:check - - run: pnpm benchmark:check + - name: Check comparison bundle baseline + id: benchmark-check + run: pnpm benchmark:check + + - name: Upload comparison bundle baseline candidate + if: ${{ always() && steps.benchmark-check.outcome != 'skipped' && (steps.benchmark-check.outcome == 'failure' || inputs.upload_bundle_baseline_candidate) }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: comparison-bundle-baseline-${{ github.sha }} + path: .benchmark-output/results/bundle-baseline.candidate.json + if-no-files-found: error + - run: pnpm catalog:check - run: pnpm catalog:build + - run: pnpm catalog:loading:check - run: git diff --check - name: Upload production catalog @@ -59,6 +79,8 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: corepack enable @@ -87,6 +109,8 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: corepack enable @@ -97,8 +121,6 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm exec playwright install --with-deps chromium - - run: pnpm catalog:build - - run: pnpm catalog:loading:check - name: Run pull-request conformance if: github.event_name == 'pull_request' run: pnpm conformance:quick @@ -124,6 +146,8 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: corepack enable @@ -164,6 +188,9 @@ jobs: - conformance runs-on: ubuntu-24.04 timeout-minutes: 10 + concurrency: + group: catalog-dist-publication + cancel-in-progress: false permissions: contents: write @@ -182,6 +209,18 @@ jobs: env: CATALOG_BRANCH: catalog-dist run: | + source_revision="$(jq -er '.revision | select(test("^[0-9a-f]{40}$"))' .catalog-artifact/catalog.json)" + if [ "$source_revision" != "$GITHUB_SHA" ]; then + echo "Catalog revision $source_revision does not match workflow revision $GITHUB_SHA." >&2 + exit 1 + fi + + git fetch origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$source_revision" origin/main; then + echo "Catalog revision $source_revision is not on the current main branch." >&2 + exit 1 + fi + publication_dir="$(mktemp -d)" if git ls-remote --exit-code --heads origin "$CATALOG_BRANCH" >/dev/null 2>&1; then git fetch origin "$CATALOG_BRANCH" @@ -191,6 +230,25 @@ jobs: git -C "$publication_dir" switch --orphan "$CATALOG_BRANCH" fi + if [ -f "$publication_dir/catalog.json" ]; then + published_revision="$(jq -er '.revision | select(test("^[0-9a-f]{40}$"))' "$publication_dir/catalog.json")" + if [ "$published_revision" = "$source_revision" ]; then + exit 0 + fi + if git merge-base --is-ancestor "$published_revision" origin/main 2>/dev/null; then + if git merge-base --is-ancestor "$source_revision" "$published_revision"; then + echo "Skipping stale catalog revision $source_revision; $published_revision is already published." + exit 0 + fi + if ! git merge-base --is-ancestor "$published_revision" "$source_revision"; then + echo "Catalog revision $source_revision diverges from published revision $published_revision." >&2 + exit 1 + fi + else + echo "Replacing legacy catalog revision $published_revision with canonical main revision $source_revision." + fi + fi + git -C "$publication_dir" rm -rf --ignore-unmatch . cp -R "$GITHUB_WORKSPACE/.catalog-artifact/." "$publication_dir/" git -C "$publication_dir" add --all diff --git a/API-FRICTION.md b/API-FRICTION.md index f88a803a..abd70870 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -602,9 +602,16 @@ Each entry records: update, and output-complexity measurements. - Decision: baseline maintenance commands report their own result without writing the canonical comparison files. Explicit size and browser benchmark - commands still write the selected facets. + commands still write the selected facets. Baseline checks write a separate + complete candidate file; pinned CI uploads it on failure or explicit manual + request so an intentional change can use the canonical runner measurements. - Verification: the bundle baseline passes without changing the restored - standard comparison report. + standard comparison report, and its candidate artifact includes passing + references as well as failures that appear in the concise console output. + Linux x64/Node 24.18.0 measurements confirmed that the renderer-neutral SVG + host added 2,811–2,849 minified bytes across the 12 TanStack fixtures while + polar, geo, and canvas modules retained zero bytes. The reviewed values now + form the canonical baseline without widening its 3%/512-byte tolerance. ### F-026 — Facet rollup tables did not explain the overall result diff --git a/benchmarks/comparison/README.md b/benchmarks/comparison/README.md index 8544575d..cac890ef 100644 --- a/benchmarks/comparison/README.md +++ b/benchmarks/comparison/README.md @@ -66,7 +66,11 @@ size change is intentional. The check permits 3% or 512 bytes, whichever is larger. It also requires normal comparison artifacts to contain zero bytes from the stress-probe modules; optional measurement machinery must disappear through direct build-time feature gates. It does not gate browser timings -because those are hardware-sensitive. +because those are hardware-sensitive. Every check writes +`.benchmark-output/results/bundle-baseline.candidate.json` with all measured +cases. CI uploads that candidate when the baseline fails; a manual workflow run +can request it explicitly. This keeps exact Ubuntu measurements available +without overwriting the tracked baseline or the full comparison report. ## Protocol diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 75f7447e..e6dbd081 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,94 +1,94 @@ { "schemaVersion": 1, - "generatedAt": "2026-07-28T08:24:08.653Z", + "generatedAt": "2026-07-29T05:42:29.634Z", "tolerance": { "relative": 0.03, "minimumBytes": 512 }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 47723, - "gzipBytes": 18701, - "brotliBytes": 16759, - "incrementalGzipBytes": 18701, - "incrementalBrotliBytes": 16759 + "minifiedBytes": 50567, + "gzipBytes": 19672, + "brotliBytes": 17598, + "incrementalGzipBytes": 19672, + "incrementalBrotliBytes": 17598 }, "tanstack-line-interactive": { - "minifiedBytes": 48639, - "gzipBytes": 18998, - "brotliBytes": 17072, - "incrementalGzipBytes": 18998, - "incrementalBrotliBytes": 17072 + "minifiedBytes": 51483, + "gzipBytes": 19981, + "brotliBytes": 17910, + "incrementalGzipBytes": 19981, + "incrementalBrotliBytes": 17910 }, "tanstack-line-advanced": { - "minifiedBytes": 55818, - "gzipBytes": 21272, - "brotliBytes": 19032, - "incrementalGzipBytes": 21272, - "incrementalBrotliBytes": 19032 + "minifiedBytes": 58661, + "gzipBytes": 22260, + "brotliBytes": 19857, + "incrementalGzipBytes": 22260, + "incrementalBrotliBytes": 19857 }, "tanstack-bar-basic": { - "minifiedBytes": 49316, - "gzipBytes": 19239, - "brotliBytes": 17255, - "incrementalGzipBytes": 19239, - "incrementalBrotliBytes": 17255 + "minifiedBytes": 52164, + "gzipBytes": 20209, + "brotliBytes": 18092, + "incrementalGzipBytes": 20209, + "incrementalBrotliBytes": 18092 }, "tanstack-bar-interactive": { - "minifiedBytes": 50228, - "gzipBytes": 19536, - "brotliBytes": 17508, - "incrementalGzipBytes": 19536, - "incrementalBrotliBytes": 17508 + "minifiedBytes": 53076, + "gzipBytes": 20527, + "brotliBytes": 18334, + "incrementalGzipBytes": 20527, + "incrementalBrotliBytes": 18334 }, "tanstack-bar-advanced": { - "minifiedBytes": 51618, - "gzipBytes": 20051, - "brotliBytes": 17987, - "incrementalGzipBytes": 20051, - "incrementalBrotliBytes": 17987 + "minifiedBytes": 54466, + "gzipBytes": 21079, + "brotliBytes": 18832, + "incrementalGzipBytes": 21079, + "incrementalBrotliBytes": 18832 }, "tanstack-area-basic": { - "minifiedBytes": 47581, - "gzipBytes": 18687, - "brotliBytes": 16751, - "incrementalGzipBytes": 18687, - "incrementalBrotliBytes": 16751 + "minifiedBytes": 50430, + "gzipBytes": 19657, + "brotliBytes": 17587, + "incrementalGzipBytes": 19657, + "incrementalBrotliBytes": 17587 }, "tanstack-area-interactive": { - "minifiedBytes": 48502, - "gzipBytes": 19003, - "brotliBytes": 17110, - "incrementalGzipBytes": 19003, - "incrementalBrotliBytes": 17110 + "minifiedBytes": 51348, + "gzipBytes": 19966, + "brotliBytes": 17871, + "incrementalGzipBytes": 19966, + "incrementalBrotliBytes": 17871 }, "tanstack-area-advanced": { - "minifiedBytes": 56916, - "gzipBytes": 21767, - "brotliBytes": 19444, - "incrementalGzipBytes": 21767, - "incrementalBrotliBytes": 19444 + "minifiedBytes": 59760, + "gzipBytes": 22761, + "brotliBytes": 20298, + "incrementalGzipBytes": 22761, + "incrementalBrotliBytes": 20298 }, "tanstack-scatter-basic": { - "minifiedBytes": 47231, - "gzipBytes": 18515, - "brotliBytes": 16619, - "incrementalGzipBytes": 18515, - "incrementalBrotliBytes": 16619 + "minifiedBytes": 50042, + "gzipBytes": 19481, + "brotliBytes": 17421, + "incrementalGzipBytes": 19481, + "incrementalBrotliBytes": 17421 }, "tanstack-scatter-interactive": { - "minifiedBytes": 48147, - "gzipBytes": 18814, - "brotliBytes": 16890, - "incrementalGzipBytes": 18814, - "incrementalBrotliBytes": 16890 + "minifiedBytes": 50958, + "gzipBytes": 19792, + "brotliBytes": 17729, + "incrementalGzipBytes": 19792, + "incrementalBrotliBytes": 17729 }, "tanstack-scatter-advanced": { - "minifiedBytes": 48163, - "gzipBytes": 18821, - "brotliBytes": 16894, - "incrementalGzipBytes": 18821, - "incrementalBrotliBytes": 16894 + "minifiedBytes": 50974, + "gzipBytes": 19799, + "brotliBytes": 17701, + "incrementalGzipBytes": 19799, + "incrementalBrotliBytes": 17701 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/examples/conformance/vite.config.ts b/examples/conformance/vite.config.ts index 7b1deeca..bbef0bb6 100644 --- a/examples/conformance/vite.config.ts +++ b/examples/conformance/vite.config.ts @@ -1,7 +1,12 @@ -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' +import { + catalogBuildGraphPath, + catalogBuildGraphSchemaVersion, +} from '../../scripts/catalog-artifact.mjs' export default defineConfig({ base: normalizeBasePath(process.env.CATALOG_BASE_PATH ?? '/'), + plugins: [catalogBuildGraphPlugin()], build: { manifest: true, target: 'es2022', @@ -12,3 +17,47 @@ function normalizeBasePath(value: string): string { const leadingSlash = value.startsWith('/') ? value : `/${value}` return leadingSlash.endsWith('/') ? leadingSlash : `${leadingSlash}/` } + +function catalogBuildGraphPlugin(): Plugin { + return { + name: 'catalog-build-graph', + apply: 'build', + generateBundle(_options, bundle) { + const chunks = Object.values(bundle) + .flatMap((output) => { + if (output.type !== 'chunk') return [] + return [ + { + fileName: output.fileName, + facadeModuleId: output.facadeModuleId, + isEntry: output.isEntry, + isDynamicEntry: output.isDynamicEntry, + imports: [...output.imports].sort(), + dynamicImports: [...output.dynamicImports].sort(), + modules: Object.keys(output.modules).sort(), + }, + ] + }) + .sort((left, right) => + left.fileName < right.fileName + ? -1 + : left.fileName > right.fileName + ? 1 + : 0, + ) + + this.emitFile({ + type: 'asset', + fileName: catalogBuildGraphPath, + source: `${JSON.stringify( + { + schemaVersion: catalogBuildGraphSchemaVersion, + chunks, + }, + null, + 2, + )}\n`, + }) + }, + } +} diff --git a/scripts/build-conformance-artifact.mjs b/scripts/build-conformance-artifact.mjs index bf7c40f0..782d7f1d 100644 --- a/scripts/build-conformance-artifact.mjs +++ b/scripts/build-conformance-artifact.mjs @@ -9,6 +9,7 @@ import { attachEmbedContract, createCatalogArtifact, serializeCatalogManifest, + validateCaseEntries, validateCatalogArtifactManifest, } from './catalog-artifact.mjs' @@ -35,7 +36,7 @@ const checkOnly = process.argv.includes('--check') const cases = await readCases() if (checkOnly) { - validateCaseIdentities(cases) + validateCaseEntries(cases) console.log(`Validated ${cases.length} publishable catalog cases.`) process.exit(0) } @@ -112,32 +113,6 @@ async function readCases() { ) } -function validateCaseIdentities(entries) { - const ids = new Set() - const orders = new Set() - - for (const { directory, metadata } of entries) { - if (metadata.id !== directory) { - throw new Error( - `Catalog id "${metadata.id}" must match directory "${directory}"`, - ) - } - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(metadata.id)) { - throw new Error( - `Catalog id "${metadata.id}" must use lowercase URL-safe words separated by hyphens`, - ) - } - if (ids.has(metadata.id)) { - throw new Error(`Duplicate catalog id "${metadata.id}"`) - } - if (orders.has(metadata.order)) { - throw new Error(`Duplicate catalog order ${metadata.order}`) - } - ids.add(metadata.id) - orders.add(metadata.order) - } -} - async function readRevision() { const configured = process.env.CATALOG_SOURCE_REVISION ?? process.env.GITHUB_SHA diff --git a/scripts/catalog-artifact.mjs b/scripts/catalog-artifact.mjs index 965b22a1..d0db41cd 100644 --- a/scripts/catalog-artifact.mjs +++ b/scripts/catalog-artifact.mjs @@ -9,6 +9,14 @@ export const catalogOrigin = 'https://tanstack.com' export const catalogArtifactFileLimit = 1_000 export const catalogArtifactFileSizeLimit = 1024 * 1024 export const catalogArtifactTotalSizeLimit = 5 * 1024 * 1024 +export const catalogBuildGraphPath = '.vite/catalog-graph.json' +export const catalogBuildGraphSchemaVersion = 1 +export const expectedCatalogImplementationCounts = Object.freeze({ + tanstack: 100, + 'observable-plot': 68, + recharts: 21, + echarts: 11, +}) const revisionPattern = /^[a-f0-9]{40}$/ const caseIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ @@ -370,7 +378,7 @@ function staticAssetClosure(root, assets) { return [...visited].sort(compareStrings) } -function validateCaseEntries(entries) { +export function validateCaseEntries(entries) { const ids = new Set() const orders = new Set() diff --git a/scripts/catalog-artifact.test.mjs b/scripts/catalog-artifact.test.mjs index a0e9adcd..1d4b2733 100644 --- a/scripts/catalog-artifact.test.mjs +++ b/scripts/catalog-artifact.test.mjs @@ -168,7 +168,7 @@ describe('catalog artifact', () => { ).rejects.toThrow('invalid catalog asset path') }) - it('rejects unreferenced files and non-debug comparisons', async () => { + it('rejects assets outside the implementation closure', async () => { const artifact = await createArtifact() const catalog = attachEmbedContract(artifact.catalog, { protocol: { version: 1 }, @@ -179,6 +179,17 @@ describe('catalog artifact', () => { imports: [], dynamicImports: [], } + + expect(() => validateCatalogArtifactManifest(catalog)).toThrow( + 'assets outside the implementation closure', + ) + }) + + it('rejects non-debug comparisons', async () => { + const artifact = await createArtifact() + const catalog = attachEmbedContract(artifact.catalog, { + protocol: { version: 1 }, + }) catalog.cases[0].modules.comparison.visibility = 'public' expect(() => validateCatalogArtifactManifest(catalog)).toThrow( diff --git a/scripts/check-catalog-loading.mjs b/scripts/check-catalog-loading.mjs index 8e18983a..0b4a9a6c 100644 --- a/scripts/check-catalog-loading.mjs +++ b/scripts/check-catalog-loading.mjs @@ -2,22 +2,30 @@ import { promises as fs } from 'node:fs' import { gzipSync } from 'node:zlib' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { build } from 'vite' -import { validateCatalogArtifactManifest } from './catalog-artifact.mjs' +import { + catalogBuildGraphPath, + catalogBuildGraphSchemaVersion, + expectedCatalogImplementationCounts, + validateCatalogArtifactManifest, +} from './catalog-artifact.mjs' const rootDirectory = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ) -const catalogDirectory = path.join(rootDirectory, 'examples', 'conformance') +const buildDirectory = path.join( + rootDirectory, + 'examples', + 'conformance', + 'dist', +) const artifactDirectory = path.join(rootDirectory, '.catalog-artifact') -const catalogBasePath = '/charts/catalog/' +const catalogPath = path.join(artifactDirectory, 'catalog.json') +const graphPath = path.join(buildDirectory, ...catalogBuildGraphPath.split('/')) -const graph = await inspectCatalogGraph() -const artifact = JSON.parse( - await fs.readFile(path.join(artifactDirectory, 'catalog.json'), 'utf8'), -) +const artifact = await readRequiredJson(catalogPath) const artifactSummary = validateCatalogArtifactManifest(artifact) +const graph = await inspectCatalogGraph() verifyPublishedGraph(graph, artifact) console.log( @@ -25,20 +33,16 @@ console.log( ) async function inspectCatalogGraph() { - const result = await build({ - root: catalogDirectory, - base: catalogBasePath, - logLevel: 'silent', - build: { - manifest: true, - target: 'es2022', - write: false, - }, - }) - const outputs = Array.isArray(result) ? result : [result] - const chunks = outputs - .flatMap((output) => output.output) - .filter((output) => output.type === 'chunk') + const buildGraph = await readRequiredJson(graphPath) + assert( + isRecord(buildGraph) && + buildGraph.schemaVersion === catalogBuildGraphSchemaVersion && + Array.isArray(buildGraph.chunks), + 'catalog build graph has an invalid schema', + ) + const chunks = buildGraph.chunks + for (const chunk of chunks) validateBuildChunk(chunk) + const chunksByFile = new Map(chunks.map((chunk) => [chunk.fileName, chunk])) const entry = onlyChunk( chunks.filter((chunk) => chunk.isEntry), @@ -55,9 +59,7 @@ async function inspectCatalogGraph() { ) const initialChunks = staticClosure(entry.fileName, chunksByFile) - const initialModules = initialChunks.flatMap((chunk) => - Object.keys(chunk.modules), - ) + const initialModules = initialChunks.flatMap((chunk) => chunk.modules) assert( !initialModules.some( (module) => @@ -70,7 +72,9 @@ async function inspectCatalogGraph() { 'the local authoring entry static graph includes comparison code', ) assert( - !entry.code.includes('tanstack.test.ts'), + !initialModules.some((module) => + normalizePath(module).endsWith('/tanstack.test.ts'), + ), 'the local authoring entry registers a test module', ) @@ -88,12 +92,26 @@ async function inspectCatalogGraph() { echarts: countCaseEntries(caseEntries, 'echarts.ts'), } assert( - counts.tanstack.implementation === 100 && counts.tanstack.source === 100, - `expected 100 TanStack implementation/source entries, received ${JSON.stringify(counts.tanstack)}`, + counts.tanstack.implementation === + expectedCatalogImplementationCounts.tanstack && + counts.tanstack.source === expectedCatalogImplementationCounts.tanstack, + `expected ${expectedCatalogImplementationCounts.tanstack} TanStack implementation/source entries, received ${JSON.stringify(counts.tanstack)}`, + ) + assertReferenceCount( + counts.plot, + expectedCatalogImplementationCounts['observable-plot'], + 'Plot', + ) + assertReferenceCount( + counts.recharts, + expectedCatalogImplementationCounts.recharts, + 'Recharts', + ) + assertReferenceCount( + counts.echarts, + expectedCatalogImplementationCounts.echarts, + 'ECharts', ) - assertReferenceCount(counts.plot, 68, 'Plot') - assertReferenceCount(counts.recharts, 21, 'Recharts') - assertReferenceCount(counts.echarts, 11, 'ECharts') const unexpectedEntries = caseEntries.filter((chunk) => { const facade = normalizePath(chunk.facadeModuleId ?? '').replace( @@ -109,13 +127,16 @@ async function inspectCatalogGraph() { .join(', ')}`, ) + const entryContent = await readRequiredFile( + path.join(buildDirectory, ...entry.fileName.split('/')), + ) return { chunks, chunksByFile, comparisonCatalog, entry, - entryBytes: Buffer.byteLength(entry.code), - entryGzipBytes: gzipSync(entry.code).byteLength, + entryBytes: entryContent.byteLength, + entryGzipBytes: gzipSync(entryContent).byteLength, } } @@ -143,7 +164,7 @@ function verifyPublishedGraph(graph, artifact) { const tanstackClosureModules = staticClosure( entry.modules.tanstack.path, graph.chunksByFile, - ).flatMap((chunk) => Object.keys(chunk.modules)) + ).flatMap((chunk) => chunk.modules) assert( !tanstackClosureModules.some( (module) => @@ -263,6 +284,57 @@ function normalizePath(value) { return value.replaceAll('\\', '/') } +async function readRequiredJson(filePath) { + const source = await readRequiredFile(filePath, 'utf8') + try { + return JSON.parse(source) + } catch (error) { + throw new Error( + `Invalid JSON in ${path.relative(rootDirectory, filePath)}.`, + { cause: error }, + ) + } +} + +async function readRequiredFile(filePath, encoding) { + try { + return await fs.readFile(filePath, encoding) + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') { + throw new Error( + `Missing ${path.relative(rootDirectory, filePath)}; run \`pnpm catalog:build\` first.`, + { cause: error }, + ) + } + throw error + } +} + +function validateBuildChunk(chunk) { + assert( + isRecord(chunk) && + typeof chunk.fileName === 'string' && + (typeof chunk.facadeModuleId === 'string' || + chunk.facadeModuleId === null) && + typeof chunk.isEntry === 'boolean' && + typeof chunk.isDynamicEntry === 'boolean' && + isStringArray(chunk.imports) && + isStringArray(chunk.dynamicImports) && + isStringArray(chunk.modules), + 'catalog build graph contains an invalid chunk', + ) +} + +function isStringArray(value) { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ) +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function formatBytes(bytes) { return `${(bytes / 1024).toFixed(1)} KiB` } diff --git a/scripts/check-conformance-artifact.mjs b/scripts/check-conformance-artifact.mjs index dc2ab506..f8f2b1e1 100644 --- a/scripts/check-conformance-artifact.mjs +++ b/scripts/check-conformance-artifact.mjs @@ -4,6 +4,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { catalogArtifactFileSizeLimit, + expectedCatalogImplementationCounts, validateCatalogArtifactManifest, } from './catalog-artifact.mjs' @@ -22,13 +23,16 @@ if (Buffer.byteLength(catalogSource) > catalogArtifactFileSizeLimit) { const catalog = JSON.parse(catalogSource) const summary = validateCatalogArtifactManifest(catalog) assert( - summary.caseCount === 100, - `expected 100 cases, got ${summary.caseCount}`, + summary.caseCount === expectedCatalogImplementationCounts.tanstack, + `expected ${expectedCatalogImplementationCounts.tanstack} cases, got ${summary.caseCount}`, ) assert( - summary.referenceCounts['observable-plot'] === 68 && - summary.referenceCounts.recharts === 21 && - summary.referenceCounts.echarts === 11, + summary.referenceCounts['observable-plot'] === + expectedCatalogImplementationCounts['observable-plot'] && + summary.referenceCounts.recharts === + expectedCatalogImplementationCounts.recharts && + summary.referenceCounts.echarts === + expectedCatalogImplementationCounts.echarts, `unexpected comparison counts ${JSON.stringify(summary.referenceCounts)}`, ) diff --git a/scripts/compare-chart-libraries.mjs b/scripts/compare-chart-libraries.mjs index b309ced2..7ef0c64a 100644 --- a/scripts/compare-chart-libraries.mjs +++ b/scripts/compare-chart-libraries.mjs @@ -16,6 +16,10 @@ const outputDirectory = resolve(root, '.benchmark-output') const caseOutputDirectory = resolve(outputDirectory, 'cases') const resultDirectory = resolve(outputDirectory, 'results') const baselinePath = resolve(comparisonDirectory, 'bundle-baseline.json') +const baselineCandidatePath = resolve( + resultDirectory, + 'bundle-baseline.candidate.json', +) const chartTypes = ['line', 'bar', 'area', 'scatter'] const tiers = ['basic', 'interactive', 'advanced'] @@ -306,13 +310,14 @@ if (!maintainsBaseline) { } if (args.has('--update-baseline')) { - await writeBundleBaseline(bundles) + await writeBundleBaseline(bundles, baselinePath) console.log( `Updated ${baselinePath.slice(root.length + 1)} with ${bundles.length} cases.`, ) } if (args.has('--check')) { + await writeBundleBaseline(bundles, baselineCandidatePath) const failures = await checkBundleBaseline(bundles) if (failures.length) { console.error(`Bundle comparison failed:\n${failures.join('\n')}`) @@ -1121,7 +1126,7 @@ function selectedLibraryLabels(result) { ) } -async function writeBundleBaseline(bundles) { +async function writeBundleBaseline(bundles, targetPath) { const baseline = { schemaVersion: 1, generatedAt: new Date().toISOString(), @@ -1142,7 +1147,7 @@ async function writeBundleBaseline(bundles) { ]), ), } - await writeFile(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`) + await writeFile(targetPath, `${JSON.stringify(baseline, null, 2)}\n`) } async function checkBundleBaseline(bundles) {