From 824285c4e3922b612ef25ed83b4b1f3baa0c94b9 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 6 Oct 2026 11:31:32 +0200 Subject: [PATCH 1/5] ci: add GitHub Actions test workflow Replace the old pull request workflow with a self-contained GitHub Actions test workflow: - unit: npm install, npm test, and builds of bitcoin-mainnet and liquid-mainnet (Node 18.20.4, as in contrib/Dockerfile.base) - regtest (amd64, arm64): build contrib/Dockerfile.base (public base image as build cache) and contrib/docker-compose.yml, then wait until the regtest API reports block height 100 Runs on pull_request and pushes to master with a read-only token and no secrets. --- .github/workflows/start-gitlab.yml | 55 --------------- .github/workflows/test.yml | 95 ++++++++++++++++++++++++++ gitlab/test.yml | 105 ----------------------------- 3 files changed, 95 insertions(+), 160 deletions(-) delete mode 100644 .github/workflows/start-gitlab.yml create mode 100644 .github/workflows/test.yml delete mode 100644 gitlab/test.yml diff --git a/.github/workflows/start-gitlab.yml b/.github/workflows/start-gitlab.yml deleted file mode 100644 index 462e725d..00000000 --- a/.github/workflows/start-gitlab.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Start GitLab CI -on: - # Use pull_request_target to run the workflow from the base branch (e.g., main) - # This ensures the trusted workflow logic executes, even for PRs from forks. - # It also grants access to secrets needed for the trigger. - pull_request_target: - types: [opened, synchronize, reopened] -jobs: - trigger-gitlab: - runs-on: ubuntu-latest - steps: - - name: Start Gitlab Pipeline - env: - # Get trigger config from secrets - GL_TRIGGER_TOKEN: ${{ secrets.GL_TRIGGER_TOKEN }} - GL_TRIGGER_URL: ${{ secrets.GL_TRIGGER_URL }} - # Use a specific ref from secrets if provided, otherwise default to the PR's head branch name - GL_TRIGGER_REF: ${{ secrets.GL_TRIGGER_REF || github.event.pull_request.head.ref }} - # --- Variables to pass to GitLab --- - # The commit SHA in the GitHub PR - GITHUB_PR_SHA: ${{ github.event.pull_request.head.sha }} - # The ref (branch name) of the PR head - GITHUB_PR_REF: ${{ github.event.pull_request.head.ref }} - # The repository name (e.g., 'your-org/your-repo') - GITHUB_REPO: ${{ github.repository }} - # The GitHub token for reporting status back - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }} - run: | - # --- Safety Checks --- - # Ensure critical secrets are actually available (they should be with pull_request_target) - if [ -z "$GL_TRIGGER_TOKEN" ]; then - echo "::error::GL_TRIGGER_TOKEN secret is missing or unavailable!" - exit 1 - fi - if [ -z "$GITHUB_TOKEN" ]; then - echo "::error::GITHUB_TOKEN is empty. Secrets may not be properly accessed." - exit 1 - fi - # Ensure URL is set - if [ -z "$GL_TRIGGER_URL" ]; then - echo "::error::GL_TRIGGER_URL secret is missing or unavailable!" - exit 1 - fi - - echo "Triggering GitLab pipeline for SHA: ${GITHUB_PR_SHA}" - curl --fail --silent --show-error --request POST \ - --form token="${GL_TRIGGER_TOKEN}" \ - --form ref="${GL_TRIGGER_REF}" \ - --form "variables[GITHUB_PR_SHA]=${GITHUB_PR_SHA}" \ - --form "variables[GITHUB_PR_REF]=${GITHUB_PR_REF}" \ - --form "variables[GITHUB_REPO]=${GITHUB_REPO}" \ - --form "variables[GITHUB_REPO_URL]=${GITHUB_REPO_URL}" \ - "${GL_TRIGGER_URL}" > /dev/null - echo "GitLab pipeline triggered." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..fc951c74 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,95 @@ +name: Test + +# Runs on pull requests (including forks) and on pushes to master. +# Uses `pull_request`, not `pull_request_target`: fork code runs with a +# read-only token and no access to repository secrets. No secrets are used. +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: test-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Unit tests and build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 18.20.4 # same as contrib/Dockerfile.base + + - name: Install + run: npm install --no-audit --no-fund + + - name: Test + run: npm test + + - name: Build (bitcoin-mainnet, liquid-mainnet) + run: | + DEST=dist/bitcoin-mainnet npm run dist -- bitcoin-mainnet + DEST=dist/liquid-mainnet npm run dist -- liquid-mainnet + + regtest: + # Same check the old GitLab job ran: build the images and run the regtest + # explorer from contrib/docker-compose.yml until its API reports block 100. + name: Docker regtest (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build base image + # The published base image is only a cache source (public pull, no login). + run: | + docker pull "blockstream/esplora-base:latest-${{ matrix.arch }}" || true + docker build \ + -f contrib/Dockerfile.base \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from "blockstream/esplora-base:latest-${{ matrix.arch }}" \ + -t blockstream/esplora-base:latest . + + - name: Build and start the regtest explorer + run: docker compose -f contrib/docker-compose.yml up --build -d + + - name: Wait for block height 100 + run: | + for i in $(seq 1 60); do + height=$(curl --fail --silent --max-time 10 http://localhost/regtest/api/blocks/tip/height || true) + if [ "$height" = "100" ]; then + echo "Success: block height is 100." + exit 0 + fi + echo "Attempt $i: height '${height}', waiting..." + sleep 5 + done + echo "Failure: block height did not reach 100." + exit 1 + + - name: Show container logs + if: failure() + run: docker compose -f contrib/docker-compose.yml logs --tail 200 + + - name: Stop + if: always() + run: docker compose -f contrib/docker-compose.yml down diff --git a/gitlab/test.yml b/gitlab/test.yml deleted file mode 100644 index 2f6361bc..00000000 --- a/gitlab/test.yml +++ /dev/null @@ -1,105 +0,0 @@ -.report_github_status: - stage: build - variables: - GIT_STRATEGY: none - rules: - # Only run this job if: - # 1. The pipeline was started by an API trigger (`CI_PIPELINE_SOURCE == "trigger"`) - # 2. The required variables from GitHub Actions are present. - - if: '$CI_PIPELINE_SOURCE == "trigger" && $GITHUB_PR_SHA && $GH_STATUS_TOKEN && $GITHUB_REPO && $GITHUB_PR_REF && $GITHUB_REPO_URL' - before_script: - - echo "$DOCKERHUB_PASSWORD" | docker login -u $DOCKERHUB_USER --password-stdin - # Make sure curl and git are available - - apk add --no-cache curl git - # Report "pending" status to GitHub as soon as the job starts - - | - echo "Reporting pending status to GitHub commit $GITHUB_PR_SHA" - curl --fail --request POST \ - --url "https://api.github.com/repos/${GITHUB_REPO}/statuses/${GITHUB_PR_SHA}" \ - --header "Authorization: Bearer ${GH_STATUS_TOKEN}" \ - --header "Accept: application/vnd.github.v3+json" \ - --header "Content-Type: application/json" \ - --data @- < Date: Tue, 6 Oct 2026 09:58:10 +0200 Subject: [PATCH 2/5] build: content-hashed asset copies + asset-manifest.json The static assets are not content-hashed (app.js, style.css, ...), so any cache in front of the explorer has to choose between long TTLs (deploys take hours to show up, and new HTML can load old JS/CSS) and frequent revalidation. The build now writes content-hashed copies (sha256, 12 hex) of app.js, style.css, style-rtl.css, instascan.min.js, js/infinite-scroll.js and the fonts referenced from the CSS, keeps the original files, and writes asset-manifest.json. index.html, notfound.html and the prerender server reference the hashed names, so deployments can serve hashed files with `Cache-Control: public, max-age=31536000, immutable` and keep only the HTML short-lived. The stylesheet link carries data-ltr/data-rtl so the Hebrew switch picks the hashed RTL file (HTML cached before this change still works). Fonts are rewritten inside the CSS before the CSS is hashed. HASH_ASSETS=0 restores the old names (no manifest, no copies). Also fixes two absolute paths that broke when the explorer is served under a prefix: lander.js loaded `${staticRoot}/js/infinite-scroll.js` (double slash) and style.css used url('/img/icons/arrow-right-orange.svg'). --- README.md | 19 ++++++ build.sh | 18 ++++- client/index.pug | 9 ++- client/src/app.js | 11 ++- client/src/driver/instascan.js | 5 +- client/src/views/lander.js | 3 +- prerender-server/src/server.js | 4 +- render-view.js | 3 +- scripts/asset-manifest.js | 21 ++++++ scripts/hash-assets.js | 65 ++++++++++++++++++ test/hash-assets.test.js | 121 +++++++++++++++++++++++++++++++++ www/style.css | 2 +- 12 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 scripts/asset-manifest.js create mode 100644 scripts/hash-assets.js create mode 100644 test/hash-assets.test.js diff --git a/README.md b/README.md index 8ad9790f..80bc94e4 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,25 @@ and run `$ npm run dist`. The files will be created under `dist/`. Because Esplora is a single-page app, the HTTP server needs to be configured to serve the `index.html` file in reply to missing pages. See [`contrib/nginx.conf.in`](contrib/nginx.conf.in) for example nginx configuration (TL;DR: `try_files $uri /index.html`). +### Content-hashed frontend assets + +Production builds create content-hashed copies of the app, stylesheets, lazy-loaded +scripts and fonts referenced directly by the stylesheets by default. Original +filenames are retained for rollback and cached HTML. `asset-manifest.json` in the +output directory (`dist/`, or `DEST`) maps original relative paths to hashed paths. +Set `HASH_ASSETS=0` to disable hashing and use plain filenames without a manifest. +The adjacent path fixes and stylesheet language-switching changes still apply. + +Both server-side renderers accept `ASSET_MANIFEST` pointing to this file (default: +`DEST/asset-manifest.json`, or the project's `dist/asset-manifest.json`). Missing +manifests fall back to plain filenames; `STATIC_ROOT` is applied to either form. + +Deployment must upload new hashed files with +`Cache-Control: public, max-age=31536000, immutable` **before** updating HTML, and +must not delete old hashed files immediately after deployment. These upload and +retention rules belong to the separate **oracle** repo. Hashed-asset misses must +return a real 404 rather than the SPA HTML fallback mentioned above. + ## Pre-rendering server (noscript) To start a pre-rendering server that generates static HTML replies suitable for noscript users, run: diff --git a/build.sh b/build.sh index 3bb37a98..fb9d671d 100755 --- a/build.sh +++ b/build.sh @@ -31,9 +31,6 @@ cp -RL www/* $CUSTOM_ASSETS $DEST/ # Assemble base CSS modules followed by flavor customizations node scripts/assemble-css.js "$DEST/style.css" $CUSTOM_CSS -# Index HTML -pug client/index.pug -o $DEST - # Open search (requires absolute CANONICAL_URL) if [ -n "$CANONICAL_URL" ]; then pug client/opensearch.pug -E xml -o $DEST @@ -42,11 +39,26 @@ fi # RTLify CSS cat $DEST/style.css | node -p "require('cssjanus').transform(fs.readFileSync('/dev/stdin').toString(), false, true)" > $DEST/style-rtl.css +# Resolve lazy-loaded scripts before envify bakes their filenames into app.js. +export ASSET_MANIFEST="$DEST/asset-manifest.json" +unset INSTASCAN_ASSET INFINITE_SCROLL_ASSET +if [[ "$HASH_ASSETS" != "0" ]]; then + node scripts/hash-assets.js "$DEST" --scripts-only + export INSTASCAN_ASSET=$(node -p "require('./scripts/asset-manifest').loadAssetManifest()['instascan.min.js']") + export INFINITE_SCROLL_ASSET=$(node -p "require('./scripts/asset-manifest').loadAssetManifest()['js/infinite-scroll.js']") +fi + # Browserify bundle # --no-dedupe needed due to https://github.com/substack/bundle-collapser/issues/20 https://github.com/browserify/browserify/issues/1450 (cd client && browserify --no-dedupe -p bundle-collapser/plugin src/run-browser.js \ | ( [[ "$NODE_ENV" != "development" ]] && uglifyjs -cm || cat ) ) \ > $DEST/app.js +# Fonts are rewritten before CSS hashing; HTML is rendered only after app.js is hashed. +if [[ "$HASH_ASSETS" != "0" ]]; then + node scripts/hash-assets.js "$DEST" +fi +pug client/index.pug -o $DEST -O "$(node scripts/asset-manifest.js)" + # Pre-render notfound.html babel-node render-view.js '{"view":"error","error":"Page Not Found"}' > $DEST/notfound.html diff --git a/client/index.pug b/client/index.pug index a40194f7..e4aae793 100644 --- a/client/index.pug +++ b/client/index.pug @@ -4,7 +4,10 @@ doctype html - staticRoot = process.env.STATIC_ROOT || '' - pageTitle = prerender_title ? `${prerender_title} ยท ${baseTitle}` : baseTitle -- cssPath = t ? t`style.css` : 'style.css' +- assets = typeof assetManifest === 'object' && assetManifest || {} +- ltrPath = staticRoot + (assets['style.css'] || 'style.css') +- rtlPath = staticRoot + (assets['style-rtl.css'] || 'style-rtl.css') +- cssPath = t && t`style.css` === 'style-rtl.css' ? rtlPath : ltrPath - lang = t ? t.lang_id : 'en' - dir = t ? t`ltr` : 'ltr' @@ -29,7 +32,7 @@ head meta(name='viewport', content='width=device-width, initial-scale=1') link(rel='shortcut icon', type='image/png', href=staticRoot+'img/favicon.png') - link(rel='stylesheet', href=staticRoot+cssPath) + link(rel='stylesheet', href=cssPath, data-ltr=ltrPath, data-rtl=rtlPath) if process.env.CANONICAL_URL //- open search requires the absolute URL of the explorer @@ -53,5 +56,5 @@ body(lang=lang, dir=dir) .nojs-shim p This block explorer needs JavaScript to run. Please enable it and reload the page. - script(src=staticRoot+'app.js', async) + script(src=staticRoot+(assets['app.js'] || 'app.js'), async) != process.env.FOOT_HTML diff --git a/client/src/app.js b/client/src/app.js index a5ff5f08..1ea9f3e3 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -799,9 +799,14 @@ export default function main( // Click-to-copy if (navigator.clipboard) copy$.subscribe(text => navigator.clipboard.writeText(text)) - // Switch stylesheet based on current language - const stylesheet = document.querySelector('link[href="style.css"]') - t$.map(t => t`style.css`).distinctUntilChanged().subscribe(styleSrc => + // Keep the existing translation's RTL marker, but resolve URLs from the rendered link. + const stylesheet = document.querySelector('link[rel="stylesheet"][data-ltr]') + || document.querySelector('link[rel="stylesheet"]') // HTML cached before hashing was introduced + t$.map(t => { + const rtl = t`style.css` === 'style-rtl.css' + return stylesheet.getAttribute(rtl ? 'data-rtl' : 'data-ltr') + || (process.env.STATIC_ROOT || '') + (rtl ? 'style-rtl.css' : 'style.css') + }).distinctUntilChanged().subscribe(styleSrc => stylesheet.getAttribute('href') != styleSrc && (stylesheet.href = styleSrc)) // Apply language and text direction to root element diff --git a/client/src/driver/instascan.js b/client/src/driver/instascan.js index e946696e..63112690 100644 --- a/client/src/driver/instascan.js +++ b/client/src/driver/instascan.js @@ -1,6 +1,7 @@ import { Observable as O } from '../rxjs' const staticRoot = process.env.STATIC_ROOT || '' + , instascanAsset = process.env.INSTASCAN_ASSET || 'instascan.min.js' // check for WebRTC camera support if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { @@ -11,11 +12,11 @@ if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { if (loaded) return; loaded = true; const script = document.createElement('script') - script.src = `${staticRoot}instascan.min.js` + script.src = `${staticRoot}${instascanAsset}` document.body.appendChild(script) } - const Instascan$ = O.fromEvent(document.body, 'load', true).filter(e => e.target?.src?.endsWith('/instascan.min.js')).map(_ => window.Instascan).share() + const Instascan$ = O.fromEvent(document.body, 'load', true).filter(e => e.target?.src?.endsWith(`/${instascanAsset}`)).map(_ => window.Instascan).share() , Scanner$ = Instascan$.map(Instascan => Instascan.Scanner) , Camera$ = Instascan$.map(Instascan => Instascan.Camera) diff --git a/client/src/views/lander.js b/client/src/views/lander.js index da0d390f..a6b55249 100644 --- a/client/src/views/lander.js +++ b/client/src/views/lander.js @@ -1,6 +1,7 @@ import layout from './layout' const staticRoot = process.env.STATIC_ROOT || '' + , infiniteScrollAsset = process.env.INFINITE_SCROLL_ASSET || 'js/infinite-scroll.js' const logoSources = { sideswap: `${staticRoot}img/logos/sideswap.svg`, @@ -16,7 +17,7 @@ const LandingPage = ({ t, ...S }) => { const logos = logoSources return layout(
- +
diff --git a/prerender-server/src/server.js b/prerender-server/src/server.js index ab979c33..f0bf2d75 100644 --- a/prerender-server/src/server.js +++ b/prerender-server/src/server.js @@ -7,6 +7,7 @@ import promClient from 'prom-client' import l10n from '../client/l10n' import render from '../client/run-server' +import { loadAssetManifest } from '../../scripts/asset-manifest' if (!process.env.API_URL) { throw new Error('API_URL environment variable is required but not defined.'); @@ -136,7 +137,8 @@ function doRender(req, res, next) { res.status(resp.status || 200) res.render(indexView, { - prerender_title: resp.title + assetManifest: loadAssetManifest() + , prerender_title: resp.title , prerender_html: resp.html , canon_url: canonBase ? canonBase + req.url : null , noscript: true diff --git a/render-view.js b/render-view.js index 1aa2f63b..37e6233a 100644 --- a/render-view.js +++ b/render-view.js @@ -9,7 +9,8 @@ state.t = l10n[state.lang || 'en'] state.page = { pathname: '', query: {} } require('pug').renderFile('client/index.pug', { - prerender_html: require('snabbdom-to-html')(view(state)) + assetManifest: require('./scripts/asset-manifest').loadAssetManifest() + , prerender_html: require('snabbdom-to-html')(view(state)) }, (err, html) => { if (err) throw err console.log(html) diff --git a/scripts/asset-manifest.js b/scripts/asset-manifest.js new file mode 100644 index 00000000..2430815e --- /dev/null +++ b/scripts/asset-manifest.js @@ -0,0 +1,21 @@ +const fs = require('fs') +const path = require('path') + +// Shared by the static build and both server-side renderers. Dev needs no manifest. +function loadAssetManifest() { + if (process.env.HASH_ASSETS === '0') return {} + const filename = process.env.ASSET_MANIFEST + || path.resolve(__dirname, '..', process.env.DEST || 'dist', 'asset-manifest.json') + try { + return JSON.parse(fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error.code === 'ENOENT') return {} + throw error + } +} + +module.exports = { loadAssetManifest } + +if (require.main === module) { + console.log(JSON.stringify({ assetManifest: loadAssetManifest() })) +} diff --git a/scripts/hash-assets.js b/scripts/hash-assets.js new file mode 100644 index 00000000..41630df0 --- /dev/null +++ b/scripts/hash-assets.js @@ -0,0 +1,65 @@ +const fs = require('fs') +const path = require('path') +const { createHash } = require('crypto') + +const lazyScripts = [ 'instascan.min.js', 'js/infinite-scroll.js' ] +const stylesheets = [ 'style.css', 'style-rtl.css' ] + +function hashAssets(destination, scriptsOnly=false) { + const root = path.resolve(destination) + , manifestPath = path.join(root, 'asset-manifest.json') + , previous = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : {} + , originals = Object.fromEntries(Object.entries(previous).map(([ original, hashed ]) => [ hashed, original ])) + , manifest = {} + + function copyHashed(relative) { + if (manifest[relative]) return manifest[relative] + const content = fs.readFileSync(path.join(root, relative)) + , hash = createHash('sha256').update(content).digest('hex').slice(0, 12) + , extension = path.extname(relative) + , hashed = `${relative.slice(0, -extension.length)}.${hash}${extension}` + fs.writeFileSync(path.join(root, hashed), content) + manifest[relative] = hashed + return hashed + } + + lazyScripts.forEach(copyHashed) + + if (!scriptsOnly) { + for (const stylesheet of stylesheets) { + const filename = path.join(root, stylesheet) + , css = fs.readFileSync(filename, 'utf8') + , rewritten = css.replace(/url\(\s*(['"]?)([^'"\)]+?)\1\s*\)/gi, (match, quote, url) => { + // Only local font URLs belong to phase 1. Preserve query strings/fragments. + if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(url)) return match + const [ , pathname, suffix ] = url.match(/^([^?#]+)([?#].*)?$/) || [] + if (!pathname || !/\.(woff2?|ttf)$/i.test(pathname)) return match + const absolute = pathname.startsWith('/') + , relative = path.posix.normalize(absolute ? pathname.slice(1) : path.posix.join(path.posix.dirname(stylesheet), pathname)) + if (relative.startsWith('../')) throw new Error(`Font URL escapes output directory: ${url}`) + // Re-running the script must not hash an already hashed filename again. + const original = originals[relative] || relative + , hashed = copyHashed(original) + , rewrittenUrl = absolute ? '/' + hashed : path.posix.relative(path.posix.dirname(stylesheet), hashed) + return `url(${quote}${rewrittenUrl}${suffix || ''}${quote})` + }) + fs.writeFileSync(filename, rewritten) + copyHashed(stylesheet) + } + copyHashed('app.js') + } + + const sorted = Object.fromEntries(Object.entries(manifest).sort(([ a ], [ b ]) => a.localeCompare(b, 'en'))) + fs.writeFileSync(manifestPath, JSON.stringify(sorted, null, 2) + '\n') + return sorted +} + +module.exports = { hashAssets } + +if (require.main === module && process.env.HASH_ASSETS !== '0') { + const [ destination, mode ] = process.argv.slice(2) + if (!destination || (mode && mode !== '--scripts-only')) { + throw new Error('Usage: node scripts/hash-assets.js [--scripts-only]') + } + hashAssets(destination, mode === '--scripts-only') +} diff --git a/test/hash-assets.test.js b/test/hash-assets.test.js new file mode 100644 index 00000000..d62ca352 --- /dev/null +++ b/test/hash-assets.test.js @@ -0,0 +1,121 @@ +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('fs') +const path = require('path') +const { createHash } = require('crypto') +const { execFileSync } = require('child_process') +const pug = require('pug') +const { hashAssets } = require('../scripts/hash-assets') +const l10n = require('../client/src/l10n').default + +const root = path.resolve(__dirname, '..') +const digest = content => createHash('sha256').update(content).digest('hex').slice(0, 12) + +function fixture(t) { + fs.mkdirSync(path.join(root, 'dist'), { recursive: true }) + const directory = fs.mkdtempSync(path.join(root, 'dist', 'hash-test-')) + t.after(() => fs.rmSync(directory, { recursive: true, force: true })) + const files = { + 'app.js': 'app', + 'instascan.min.js': 'scanner', + 'js/infinite-scroll.js': 'scroll', + 'font/example.woff2': 'font two', + 'font/example.woff': 'font one', + 'font/example.ttf': 'font ttf', + 'font/unused.ttf': 'unused', + 'style.css': 'a{src:url("font/example.woff2?#v1")}b{src:url(./font/example.woff)}c{src:url(/font/example.ttf)}', + 'style-rtl.css': "a{src:url('font/example.ttf')}b{src:url(font/example.woff2)}", + 'img/example.svg': 'image', + 'libwally/wallycore.js': 'wally', + 'index.html': 'html', + 'robots.txt': 'robots', + 'opensearch.xml': 'search', + 'favicon.ico': 'icon', + } + for (const [ filename, content ] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(directory, filename)), { recursive: true }) + fs.writeFileSync(path.join(directory, filename), content) + } + return { directory, files } +} + +test('hashes only phase 1 assets, retains originals, and hashes final CSS bytes', t => { + const { directory, files } = fixture(t) + const prepared = hashAssets(directory, true) + assert.deepEqual(Object.keys(prepared), ['instascan.min.js', 'js/infinite-scroll.js']) + assert.equal(fs.readFileSync(path.join(directory, 'style.css'), 'utf8'), files['style.css']) + const manifest = hashAssets(directory) + assert.equal(Object.keys(manifest).length, 8) + for (const [ original, hashed ] of Object.entries(manifest)) { + const content = fs.readFileSync(path.join(directory, original)) + assert.deepEqual(content, fs.readFileSync(path.join(directory, hashed))) + assert.ok(hashed.endsWith(`.${digest(content)}${path.extname(original)}`)) + } + for (const filename of Object.keys(files).filter(filename => !manifest[filename])) { + assert.equal(fs.readFileSync(path.join(directory, filename), 'utf8'), files[filename]) + } + const css = fs.readFileSync(path.join(directory, manifest['style.css']), 'utf8') + assert.ok(css.includes(`url("${manifest['font/example.woff2']}?#v1")`)) + assert.ok(css.includes(`url(${manifest['font/example.woff']})`)) + assert.ok(css.includes(`url(/${manifest['font/example.ttf']})`)) + assert.deepEqual(hashAssets(directory), manifest, 'repeated hashing is idempotent') + + fs.appendFileSync(path.join(directory, 'font/example.woff2'), ' changed') + const changed = hashAssets(directory) + assert.notEqual(changed['font/example.woff2'], manifest['font/example.woff2']) + assert.notEqual(changed['style.css'], manifest['style.css']) + assert.notEqual(changed['style-rtl.css'], manifest['style-rtl.css']) + assert.equal(changed['app.js'], manifest['app.js']) +}) + +test('leaves external URLs unchanged and fails on missing local fonts', t => { + const { directory } = fixture(t) + const css = 'a{src:url(https://example.com/font.woff2)}b{src:url(//example.com/font.ttf)}c{src:url(data:font/woff;base64,AA)}' + fs.writeFileSync(path.join(directory, 'style.css'), css) + hashAssets(directory) + assert.equal(fs.readFileSync(path.join(directory, 'style.css'), 'utf8'), css) + fs.writeFileSync(path.join(directory, 'style.css'), 'a{src:url(font/missing.ttf)}') + assert.throws(() => hashAssets(directory), /ENOENT/) +}) + +test('HASH_ASSETS=0 skips hashing entirely and renderer loader falls back without a manifest', t => { + const { directory, files } = fixture(t) + execFileSync(process.execPath, ['scripts/hash-assets.js', directory], { + cwd: root, env: { ...process.env, HASH_ASSETS: '0' } + }) + assert.equal(fs.existsSync(path.join(directory, 'asset-manifest.json')), false) + for (const [ filename, content ] of Object.entries(files)) { + assert.equal(fs.readFileSync(path.join(directory, filename), 'utf8'), content) + } + const load = env => JSON.parse(execFileSync(process.execPath, ['scripts/asset-manifest.js'], { + cwd: root, env: { ...process.env, HASH_ASSETS: '1', ASSET_MANIFEST: path.join(directory, 'asset-manifest.json'), ...env } + })) + assert.deepEqual(load(), { assetManifest: {} }) + const manifest = hashAssets(directory) + assert.deepEqual(load(), { assetManifest: manifest }) + assert.deepEqual(load({ HASH_ASSETS: '0' }), { assetManifest: {} }) + assert.deepEqual(load({ ASSET_MANIFEST: '', DEST: directory }), { assetManifest: manifest }) + fs.writeFileSync(path.join(directory, 'asset-manifest.json'), '{broken') + assert.throws(() => load(), /SyntaxError/) +}) + +test('template resolves both directions and STATIC_ROOT consistently for every language', t => { + const previous = process.env.STATIC_ROOT + process.env.STATIC_ROOT = '/preview/assets/' + t.after(() => previous === undefined ? delete process.env.STATIC_ROOT : process.env.STATIC_ROOT = previous) + for (const assetManifest of [undefined, { + 'style.css': 'style.0123456789ab.css', + 'style-rtl.css': 'style-rtl.abcdef012345.css', + 'app.js': 'app.123456789abc.js' + }]) { + for (const [ lang, translate ] of Object.entries(l10n)) { + const html = pug.renderFile(path.join(root, 'client/index.pug'), { assetManifest, t: translate }) + , ltr = assetManifest ? assetManifest['style.css'] : 'style.css' + , rtl = assetManifest ? assetManifest['style-rtl.css'] : 'style-rtl.css' + , app = assetManifest ? assetManifest['app.js'] : 'app.js' + assert.ok(html.includes(`href="/preview/assets/${lang === 'he' ? rtl : ltr}"`), lang) + assert.ok(html.includes(`data-ltr="/preview/assets/${ltr}" data-rtl="/preview/assets/${rtl}"`)) + assert.ok(html.includes(`src="/preview/assets/${app}"`)) + } + } +}) diff --git a/www/style.css b/www/style.css index effd4ad7..1b69ea2f 100644 --- a/www/style.css +++ b/www/style.css @@ -2847,7 +2847,7 @@ a.back-link img{ font-weight: 400; line-height: 22px; padding-left: 30px; - background-image: url('/img/icons/arrow-right-orange.svg'); + background-image: url('img/icons/arrow-right-orange.svg'); background-repeat: no-repeat; background-position: left top 4px; background-size: 15px 15px; From 1aa5b28684120ba78c12e5fde3bf41f07972f413 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 6 Oct 2026 11:03:54 +0200 Subject: [PATCH 3/5] prerender: point ASSET_MANIFEST at the flavor's build The Docker image builds each flavor into /srv/explorer/static/, so the default manifest path (dist/asset-manifest.json) did not exist for the bundled prerender service and /nojs/ pages kept the unhashed asset names. --- contrib/runits/prerenderer.runit | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contrib/runits/prerenderer.runit b/contrib/runits/prerenderer.runit index ceed22c1..841b7b77 100755 --- a/contrib/runits/prerenderer.runit +++ b/contrib/runits/prerenderer.runit @@ -6,6 +6,8 @@ export NODE_ENV=${NODE_ENV:-production} export API_URL=http://127.0.0.1/{NGINX_PATH}api export TZ=UTC export SOCKET_PATH=/var/prerender-http.sock +# Content-hashed asset names for this flavor's build (see README). +export ASSET_MANIFEST={STATIC_DIR}/asset-manifest.json exec 2>&1 exec /bin/bash -c 'source /root/.nvm/nvm.sh && cd /srv/explorer/source && npm run prerender-server -- --cluster {FLAVOR}' From 69a8682e8d28fc709acc1383277a7916f1f1da68 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 6 Oct 2026 11:03:54 +0200 Subject: [PATCH 4/5] docs: describe hashed-asset deployment requirements generically --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 80bc94e4..e9f2a020 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,6 @@ scripts and fonts referenced directly by the stylesheets by default. Original filenames are retained for rollback and cached HTML. `asset-manifest.json` in the output directory (`dist/`, or `DEST`) maps original relative paths to hashed paths. Set `HASH_ASSETS=0` to disable hashing and use plain filenames without a manifest. -The adjacent path fixes and stylesheet language-switching changes still apply. Both server-side renderers accept `ASSET_MANIFEST` pointing to this file (default: `DEST/asset-manifest.json`, or the project's `dist/asset-manifest.json`). Missing @@ -109,9 +108,10 @@ manifests fall back to plain filenames; `STATIC_ROOT` is applied to either form. Deployment must upload new hashed files with `Cache-Control: public, max-age=31536000, immutable` **before** updating HTML, and -must not delete old hashed files immediately after deployment. These upload and -retention rules belong to the separate **oracle** repo. Hashed-asset misses must -return a real 404 rather than the SPA HTML fallback mentioned above. +must not delete old hashed files immediately after deployment (cached HTML and +open tabs still reference them). If the server falls back to `index.html` for +unknown paths, hashed-asset misses must return a real 404 instead, so a missing +file is never cached as HTML. ## Pre-rendering server (noscript) From 083b4c24f486f340b0cf79fe2f40f815f7e781ec Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 6 Oct 2026 11:49:17 +0200 Subject: [PATCH 5/5] prerender: use hashed lazy-script names on server-rendered pages The prerender bundle is compiled before the static builds, so envify cannot bake the hashed infinite-scroll name into it, and lander.js read the env var once at module load. The prerender server now loads the flavor's manifest once at startup and exports the lazy-script names (exportAssetEnv), and lander.js reads the name at render time. The browser bundle is unchanged: envify still inlines the literal. --- client/src/views/lander.js | 4 ++-- prerender-server/src/server.js | 8 ++++++-- scripts/asset-manifest.js | 16 +++++++++++++++- test/hash-assets.test.js | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/client/src/views/lander.js b/client/src/views/lander.js index a6b55249..19c7a1ba 100644 --- a/client/src/views/lander.js +++ b/client/src/views/lander.js @@ -1,7 +1,6 @@ import layout from './layout' const staticRoot = process.env.STATIC_ROOT || '' - , infiniteScrollAsset = process.env.INFINITE_SCROLL_ASSET || 'js/infinite-scroll.js' const logoSources = { sideswap: `${staticRoot}img/logos/sideswap.svg`, @@ -17,7 +16,8 @@ const LandingPage = ({ t, ...S }) => { const logos = logoSources return layout(
- + {/* Read at render time: the prerender server sets it from the manifest at startup. */} +
diff --git a/prerender-server/src/server.js b/prerender-server/src/server.js index f0bf2d75..1647adf7 100644 --- a/prerender-server/src/server.js +++ b/prerender-server/src/server.js @@ -7,7 +7,11 @@ import promClient from 'prom-client' import l10n from '../client/l10n' import render from '../client/run-server' -import { loadAssetManifest } from '../../scripts/asset-manifest' +import { loadAssetManifest, exportAssetEnv } from '../../scripts/asset-manifest' + +// Load the flavor's manifest once; views read the lazy-script names from env at render time. +const assetManifest = loadAssetManifest() +exportAssetEnv(assetManifest) if (!process.env.API_URL) { throw new Error('API_URL environment variable is required but not defined.'); @@ -137,7 +141,7 @@ function doRender(req, res, next) { res.status(resp.status || 200) res.render(indexView, { - assetManifest: loadAssetManifest() + assetManifest , prerender_title: resp.title , prerender_html: resp.html , canon_url: canonBase ? canonBase + req.url : null diff --git a/scripts/asset-manifest.js b/scripts/asset-manifest.js index 2430815e..2b20eafe 100644 --- a/scripts/asset-manifest.js +++ b/scripts/asset-manifest.js @@ -14,7 +14,21 @@ function loadAssetManifest() { } } -module.exports = { loadAssetManifest } +// Lazy-loaded scripts are referenced from views through env vars. The browser bundle gets +// them from envify at build time; server-side renderers set them from the manifest. +const lazyAssetEnv = { + INSTASCAN_ASSET: 'instascan.min.js', + INFINITE_SCROLL_ASSET: 'js/infinite-scroll.js', +} + +function exportAssetEnv(manifest, env=process.env) { + for (const [ name, original ] of Object.entries(lazyAssetEnv)) { + if (!env[name] && manifest[original]) env[name] = manifest[original] + } + return env +} + +module.exports = { loadAssetManifest, exportAssetEnv } if (require.main === module) { console.log(JSON.stringify({ assetManifest: loadAssetManifest() })) diff --git a/test/hash-assets.test.js b/test/hash-assets.test.js index d62ca352..e249b598 100644 --- a/test/hash-assets.test.js +++ b/test/hash-assets.test.js @@ -119,3 +119,35 @@ test('template resolves both directions and STATIC_ROOT consistently for every l } } }) + +test('exportAssetEnv sets lazy-script names from the manifest without overriding', () => { + const { exportAssetEnv } = require('../scripts/asset-manifest') + const manifest = { + 'instascan.min.js': 'instascan.min.0123456789ab.js', + 'js/infinite-scroll.js': 'js/infinite-scroll.ba9876543210.js', + } + assert.deepEqual(exportAssetEnv(manifest, {}), { + INSTASCAN_ASSET: 'instascan.min.0123456789ab.js', + INFINITE_SCROLL_ASSET: 'js/infinite-scroll.ba9876543210.js', + }) + assert.equal(exportAssetEnv(manifest, { INSTASCAN_ASSET: 'given.js' }).INSTASCAN_ASSET, 'given.js') + assert.deepEqual(exportAssetEnv({}, {}), {}) +}) + +test('server-rendered landing page reads the hashed infinite-scroll name at render time', t => { + const render = require('snabbdom-to-html') + const LandingPage = require('../client/src/views/lander').default + const previous = process.env.INFINITE_SCROLL_ASSET + t.after(() => { + if (previous === undefined) delete process.env.INFINITE_SCROLL_ASSET + else process.env.INFINITE_SCROLL_ASSET = previous + }) + const state = { t: l10n.en, page: { pathname: '/', query: {} } } + const scriptSrc = () => (render(LandingPage(state)).match(/