Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,54 @@ jobs:
path: dist/sql.html
retention-days: 14

# Bundle-size report (#275): measure the self-contained artifact on every PR and
# upload it as an artifact — raw/gzip/Brotli, per-module + per-package attribution,
# and deltas vs. the PR base when a base report can be produced. Reporting only:
# it builds the same bytes as the release, never a budget that fails the build.
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# Full history so the PR base commit is present for the base report below.
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
- run: npm ci --no-audit --no-fund
# Base report: check the PR base commit out into a worktree and run *its*
# size-report. Fully tolerant — the base predates this tooling on the first
# PR, so a failure here just means "no deltas", never a failed job.
- name: Base report (PR only, best-effort)
if: github.event_name == 'pull_request'
continue-on-error: true
run: |
set +e
base='${{ github.event.pull_request.base.sha }}'
git worktree add /tmp/base-tree "$base" || exit 0
( cd /tmp/base-tree \
&& npm ci --no-audit --no-fund \
&& npm run size-report -- --out /tmp/base-report ) || exit 0
cp /tmp/base-report/bundle-size-report.json base-report.json 2>/dev/null || true
- name: Size report
run: |
set -euo pipefail
if [ -f base-report.json ]; then
npm run size-report -- --base base-report.json
else
echo "No base report available — emitting current report without deltas."
npm run size-report
fi
- uses: actions/upload-artifact@v7
with:
name: bundle-size-report
path: |
bundle-report/bundle-size-report.json
bundle-report/bundle-size-report.md
bundle-report/esbuild-meta.json
retention-days: 14

# Release-bundle smoke test: assemble the curl|sh artifact, extract it, and boot
# the zero-dep Python runner exactly as an end user would — proving the bundle
# layout, the SPA-path discovery, and config.json generation all work before a
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Build output
/dist/

# Bundle-size report output (npm run size-report) — a CI artifact, not committed
/bundle-report/

# Test / coverage
/coverage/
/test-results/
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **Bundle-size report on every PR** (#275). `npm run size-report` builds the
production artifact once (through the same `buildArtifact` the release uses, so
the measured bytes are byte-for-byte the shipped `dist/sql.html`) and emits a
machine-readable `bundle-size-report.json`, a human-readable
`bundle-size-report.md`, and the raw `esbuild-meta.json`. It records raw/gzip/
Brotli sizes for the artifact, JS bundle, and minified CSS; attributes JS output
bytes to input modules and npm packages (hand-written `src/**`, generated
`src/generated/**`, and each external package reported separately); lists the
top-30 modules and entry-point/chunk totals; and, given a base report
(`--base`), appends absolute + percentage deltas. A new CI `size` job produces
the report on every PR — with best-effort deltas vs. the PR base — and uploads
it as an artifact. Reporting only: no bundle-size budget fails the build yet
(thresholds are a follow-up once baseline variance is known). Pure attribution/
formatting lives in `build/size-report-lib.mjs`, unit-tested in
`tests/unit/size-report.test.js`.
- **Helm chart.** A chart at `helm/altinity-sql-browser/` deploys the nginx image
to Kubernetes (Deployment + ClusterIP Service + config ConfigMap + optional
Ingress/HPA), non-root, config via `.Values.config` → `/sql/config.json`, CSP
Expand Down
44 changes: 37 additions & 7 deletions build/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { build, transform } from 'esbuild';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { realpathSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
Expand Down Expand Up @@ -41,16 +42,32 @@ async function buildStamp() {
return commit ? `v${version} (${commit})` : `v${version}`;
}

async function main() {
const result = await build({
// The esbuild options for the single production entry point. Shared verbatim by
// the release build (`main`) and the bundle-size report (build/size-report.mjs)
// so the report measures the artifact users actually receive. `metafile` is the
// only knob: the report needs esbuild's input→output byte attribution, and it is
// pure metadata that never changes the emitted bytes.
export function esbuildOptions({ metafile = false } = {}) {
return {
entryPoints: [resolve(root, 'src/main.ts')],
bundle: true,
format: 'iife',
target: 'es2020',
minify: true,
write: false,
legalComments: 'none',
});
metafile,
};
}

// Produce the exact bytes that ship in dist/sql.html without writing anything, so
// callers (the release build, the size report) share one source of truth. Returns
// the assembled `html` plus its three inlined parts — `script` (JS bundle, stamp
// substituted), `styles` (minified CSS), `thirdParty` (the notices comment) — and
// the esbuild `metafile` when requested (undefined otherwise). Keeping this the
// single builder is what guarantees the report and the release stay byte-identical.
export async function buildArtifact({ metafile = false } = {}) {
const result = await build(esbuildOptions({ metafile }));
// Replace the `__ASB_BUILD__` placeholder (a string literal in src/main.ts)
// with the build stamp before the bundle is inlined — same token-replace seam
// as the styles/script splices below. replaceAll is robust to either quote
Expand All @@ -74,12 +91,25 @@ async function main() {
.replace('/*__STYLES__*/', () => styles)
.replace('/*__SCRIPT__*/', () => script);

return { html, script, styles, thirdParty, metafile: result.metafile };
}

async function main() {
const { html } = await buildArtifact();
await mkdir(resolve(root, 'dist'), { recursive: true });
await writeFile(resolve(root, 'dist/sql.html'), html);
console.log('built dist/sql.html (' + html.length + ' bytes)');
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
// Only run the release build when invoked as a script, not when imported for its
// exports (build/size-report.mjs, tests). Compare *realpaths*: Node sets
// import.meta.url to the symlink-resolved location, so a plain resolve() of
// argv[1] (which doesn't follow symlinks) would miscompare when the checkout sits
// under a symlinked path — silently skipping the release build. realpathSync on
// argv[1] closes that gap.
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((e) => {
console.error(e);
process.exit(1);
});
}
Loading
Loading