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
55 changes: 0 additions & 55 deletions .github/workflows/start-gitlab.yml

This file was deleted.

95 changes: 95 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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 (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)

To start a pre-rendering server that generates static HTML replies suitable for noscript users, run:
Expand Down
18 changes: 15 additions & 3 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
9 changes: 6 additions & 3 deletions client/index.pug
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -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
11 changes: 8 additions & 3 deletions client/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions client/src/driver/instascan.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion client/src/views/lander.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const LandingPage = ({ t, ...S }) => {
const logos = logoSources
return layout(
<div className="landing-page">
<script src={`${staticRoot}/js/infinite-scroll.js`} async></script>
{/* Read at render time: the prerender server sets it from the manifest at startup. */}
<script src={`${staticRoot}${process.env.INFINITE_SCROLL_ASSET || 'js/infinite-scroll.js'}`} async></script>
<div className="blur-orange"></div>
<div className="blur-green"></div>
<div className="laser-lines"></div>
Expand Down
2 changes: 2 additions & 0 deletions contrib/runits/prerenderer.runit
Original file line number Diff line number Diff line change
Expand Up @@ -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}'
Loading