Skip to content

fix(server): wrap exported functions as hoisted declarations in action seed facade (#1208) #2610

fix(server): wrap exported functions as hoisted declarations in action seed facade (#1208)

fix(server): wrap exported functions as hoisted declarations in action seed facade (#1208) #2610

Workflow file for this run

name: CI
# Runs the full test pyramid on every PR into main and on every push to
# main. The three layers run as separate jobs so a failure names the
# layer that broke. Mark all three as required status checks in the
# branch-protection rule for main so a PR can only merge when unit,
# browser, AND e2e are green (not just unit). See the repo's branch
# protection settings, or run scripts/protect-main.sh.
#
# Free on public repos (ubuntu-latest has unlimited Actions minutes).
on:
pull_request:
branches: [main]
push:
branches: [main]
# A newer push to the same branch cancels the older in-flight run.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
conventions:
name: Conventions (webjs check)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
# Dogfood the framework's own correctness checks on the in-repo apps.
- name: webjs check (blog, website, docs host, ui host)
run: |
for app in examples/blog website docs packages/ui/packages/website; do
echo "::group::webjs check $app"
( cd "$app" && node "$GITHUB_WORKSPACE/packages/cli/bin/webjs.js" check )
echo "::endgroup::"
done
- name: Framework runtime packages are buildless (no .ts source)
run: |
# Invariant: packages/{core,server,cli} and packages/editors/* are
# plain .js + JSDoc. .ts is allowed in examples/docs/website and in
# scaffold templates / the ui registry (those ship to user apps).
hits=$(git ls-files 'packages/core/**/*.ts' 'packages/server/**/*.ts' 'packages/cli/**/*.ts' 'packages/editors/**/*.ts' \
| grep -vE '\.d\.ts$|/templates/' || true)
if [ -n "$hits" ]; then
echo "::error::TypeScript source in a buildless framework package (invariant violated):"; echo "$hits"; exit 1
fi
- name: No em-dash in source (invariant 11)
run: |
# U+2014 is banned repo-wide for webjs-AUTHORED prose. changelog/ is
# generated from commit history (pre-rule entries) so it is excluded,
# and .claude/skills/ holds vendored agent skills (e.g. the
# Anthropic-authored use-railway), which are external content not
# subject to our prose style, the same as node_modules.
hits=$(git grep -lP "\x{2014}" -- '*.js' '*.ts' '*.md' ':!changelog/' ':!**/node_modules/**' ':!.claude/skills/**' ':!.agents/skills/**' || true)
if [ -n "$hits" ]; then
echo "::error::em-dash (U+2014) found; replace per AGENTS.md invariant 11:"; echo "$hits"; exit 1
fi
unit:
name: Unit + integration (node --test)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
# Some integration tests boot the blog example in-process (e.g. the
# differential elision test renders its mixed `/` page), which calls
# listPosts() and needs a migrated SQLite DB, the same setup the e2e job
# does.
- name: Prepare the blog example database
working-directory: examples/blog
run: |
cp .env.example .env
npm run db:migrate
npm run db:seed
- run: npm test
bun:
name: Bun runtime smoke + test matrix (#508, #509, #511)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
# npm ci installs the workspace + the `amaro` optionalDependency that the
# Bun stripper backend needs.
- run: npm ci
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Boot a webjs app under Bun: SSR + TypeScript strip (via amaro, since Bun
# has no built-in stripper) + a server-action RPC round-trip.
- name: webjs runtime smoke on Bun
run: bun test/bun/smoke.mjs
# Boot the full LISTENER on the Bun.serve shell (#511): SSR + a route.ts GET
# + the SSE live-reload stream + a WebSocket WS-export echo over a real
# socket. The same assertions run on the node:http shell under `npm test`,
# so this is the cross-shell parity proof.
- name: webjs listener parity on Bun
run: bun test/bun/listener.mjs
# The form-action leak guard (#1154) on Bun: a divergence here is a
# divergence in whether a server action's SOURCE reaches the served HTML,
# so it cannot be left to the Node suite alone. Covers both SSR state
# machines, which are independent and must be kept in step by hand.
- name: WebJs form-action guard parity on Bun
run: bun test/bun/form-action-guard.mjs
# Form-action dispatch (#1155) on Bun: the 'use server' load hook that
# registers action identity is installed by a different mechanism on each
# runtime (module.registerHooks vs Bun.plugin), and the submission path
# runs through FormData / multipart parsing and Web Crypto. A divergence
# means a no-JS form silently posts nowhere.
- name: WebJs form-action dispatch parity on Bun
run: bun test/bun/form-action-dispatch.mjs
# Listener overhead reductions on Bun (#756): the out-of-band IP stamp (no
# Request clone), the buffered sync-compression fast path, the streamed-head
# non-blocking classifier, and the basePath rebuild spoof guard. Run as the
# plain `.mjs` script (not via `bun test`) so it is not subject to bun test's
# per-test timeout while it boots two servers + does a deliberate 400ms stall.
- name: webjs listener overhead on Bun
run: bun test/bun/listener-overhead.mjs
# FileStore streaming on Bun (#509): put/get round-trip + the
# no-orphan-on-mid-stream-error invariant (the Readable.fromWeb->reader-loop
# fix, which Readable.fromWeb hangs on under Bun).
- name: webjs FileStore streaming on Bun
run: bun test/bun/file-storage.mjs
# Compression on the Bun.serve shell (#517): brotli served via node:zlib
# (the web CompressionStream had no brotli), plus the no-hang-on-mid-stream-
# error guard (the body is fed through a reader-loop + pipeline, not
# Readable.fromWeb, which hangs on Bun).
- name: webjs compression on Bun
run: bun test/bun/compression.mjs
# SSR HTML-context handling on Bun (#1128): a tag name inside a comment,
# raw text, RCDATA, or an attribute value is text, not an element. The
# scanner is offset arithmetic over indexOf / startsWith results and a
# per-call RegExp, so a V8-versus-JSC divergence would not throw, it would
# shift a range boundary and make a component render on one runtime and
# silently vanish on the other. Asserts both directions.
- name: SSR comment and raw-text handling on Bun
run: bun test/bun/comment-not-an-element.mjs
# asset() url resolution on Bun (#1194): the helper reads the file
# synchronously, hashes it with node:crypto, and compares node:path
# containment to decide whether a url may be fingerprinted at all. A
# divergence would either hand the two runtimes different urls for one
# file (thrashing every client's immutable cache across a mixed fleet) or
# weaken the gate that keeps a private file from being hashed and
# published. Asserts the hash, the fragment split, verdict-independence,
# and the traversal refusals.
- name: asset() url resolution on Bun
run: bun test/bun/asset-url.mjs
# Dev hot reload on Bun (#514): start `webjs dev` under Bun, edit a
# re-imported route module, and assert the response updates with NO manual
# restart. The CLI re-execs under `bun --hot` on Bun (vs `node --watch` on
# Node); without it Bun ignores the dev `?t=` cache-bust and the edit stays
# stale. The same script proves no Node regression under `npm test`.
- name: webjs dev hot reload on Bun
run: bun test/bun/dev-hot-reload.mjs
# Dev live-reload of a webjs.dev.watch dir OUTSIDE the appDir (#894) and
# the SSE retry hint on the Bun.serve shell (#893). Plain scripts (no
# per-test timeout) since each boots the real CLI + polls for readiness;
# the node:test integration versions are node:http-listener-specific and
# denylisted in run-bun-tests.js.
- name: webjs dev extra-watch + reload-retry on Bun
run: |
bun test/bun/dev-extra-watch.mjs
bun test/bun/dev-reload-retry.mjs
# The app-source deploy signal (#899) is derived from an fs source walk +
# a node:crypto digest, so it must be byte-identical on the Bun.serve path.
- name: App-source deploy signal on Bun
run: bun test/bun/app-source-signal.mjs
# Boot the website (which serves /docs and /ui) on Bun and GET real
# routes (#542). Every in-repo app deploys on Bun, but only examples/blog
# had a Bun boot in CI, so a per-route break only on Bun (the #526
# component-page 500) could reach production. The script runs the app's
# webjs.start.before presteps (the ui registry copy is the #526 root
# cause) and probes a gallery component detail page. Node runs it too in
# the "In-repo app tests" job.
- name: App boot-check on Bun (website incl. /docs + /ui)
run: bun test/bun/app-boot.mjs
# SSR action-result seeding on Bun (#529): seeding rode Node's
# module.registerHooks, which Bun lacks; it now installs via a Bun.plugin
# onLoad, so a shipping async component seeds during SSR (the __webjs-seeds
# block) and does NOT re-fetch on hydration. The same script proves the Node
# install under `npm test`.
- name: webjs action seeding on Bun
run: bun test/bun/seed.mjs
# Blog Drizzle DB round-trip on Bun (#551/#563): the connection picks
# bun:sqlite + drizzle-orm/bun-sqlite on Bun (vs node:sqlite on Node),
# so prove the blog's real schema round-trips an insert().returning(), a
# timestamp_ms Date column, and a relational read on the Bun driver. The
# same script proves the Node path under `npm test`.
- name: webjs blog DB round-trip on Bun
run: bun test/bun/blog-db.mjs
# # path-alias resolution on Bun (#555): the alias is Node's native
# package.json "imports" field; Bun must resolve the same
# `#*` catch-all key (a `#/`-prefixed key does NOT resolve on Bun, which is
# why the scaffold ships the slash-free `#*` form). The same script proves the Node path
# under `npm test`.
- name: "webjs # path alias on Bun"
run: bun test/bun/path-alias.mjs
# Server timeout wiring on Bun (#663): the node:http requestTimeout /
# headersTimeout / keepAliveTimeout map onto Bun.serve's single idleTimeout
# (#511); this asserts startBunListener feeds the mapped value into
# Bun.serve. The pure mapping is also unit-tested under the matrix below.
- name: webjs server timeouts on Bun
run: bun test/bun/timeouts.mjs
# Template binding-prefix dispatch on Bun (#784): the renderers now read
# the prefix set from core's single-sourced BINDING_PREFIXES and dispatch
# on kind; this asserts @event drops and .prop / ?bool round-trip the same
# on the Bun SSR path (buffered and streamed) as on Node.
- name: webjs binding-prefix dispatch on Bun
run: bun test/bun/binding-prefixes.mjs
# Reverse-proxy forwarded headers on Bun (#1090): the node shell corrects
# the request url via urlFromRequest before building its Request, but the
# Bun shell handed Bun.serve's request straight through, so behind a
# TLS-terminating proxy every absolute URL an app derived came out http://
# (webjs.dev served an http:// og:image). This asserts a page's ctx.url AND
# a route handler's raw req.url both carry the forwarded scheme + host.
- name: Forwarded proto/host on Bun
run: bun test/bun/forwarded-proto.mjs
# Root middleware resolution on Bun (#1098). A root `middleware.ts` was
# never loaded at all: the lookup was the single literal `middleware.js`,
# with no error to notice. The proof is a module-LOAD path (a bare
# import() of a .ts file), and the TS strip differs per runtime, so it
# needs its own step rather than riding the Node suite.
- name: Root middleware resolution on Bun
run: bun test/bun/root-middleware.mjs
# Light-DOM slot SSR projection on Bun (#1021): slot substitution
# (injectDSD / substituteSlotsInRender) is on the SSR hot path, so the
# projection must be byte-consistent across runtimes: authored children
# land in their named + default slots, an unmatched slot falls back, and
# the data-webjs-light + data-projection markers are emitted. The same
# script proves the Node path under `npm test`.
- name: Light-DOM slot SSR projection on Bun
run: bun test/bun/slot-ssr-parity.mjs
# SQLite busy_timeout on Bun (#673): bun:sqlite (like node:sqlite) defaults
# busy_timeout to 0, so a contended write throws `database is locked`; the
# connection tune sets busy_timeout=5000 + WAL. This asserts the bug
# condition and the fix on the Bun driver. The same script proves the Node
# path under `npm test`.
- name: webjs SQLite busy_timeout on Bun
run: bun test/bun/sqlite-busy-timeout.mjs
# The Bun test MATRIX (#509): run the runtime-sensitive node:test suite
# (core + server + cross-package test/) under Bun, file by file, classifying
# each result. Documented Node-only files + Bun-test-runner-quirk files are
# skipped with a reason; genuine Bun failures fail the job. This is what
# catches the long tail of cross-runtime incompatibilities.
- name: webjs Bun test matrix
run: node scripts/run-bun-tests.js
db-postgres:
name: Postgres prod-engine round-trip (#563)
runs-on: ubuntu-latest
# The cross-database Drizzle abstraction (#563) makes the schema, queries,
# and actions portable, but migrations + runtime behavior are per-dialect.
# The rest of CI runs on SQLite, so this job proves the SAME unified schema
# round-trips on a REAL Postgres engine (a service container), the
# dev-SQLite / prod-Postgres workflow the abstraction promises.
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: webjs_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
# The `pg` driver is installed only in THIS job (not a root devDependency),
# so it stays out of the shared package-lock. --no-save keeps the lock
# untouched; this job does not run the type fixtures, so the extra install
# cannot affect them.
- run: npm install --no-save pg@^8.13.0
- name: Postgres round-trip (unified schema on the prod engine)
env:
WEBJS_PG_URL: postgres://postgres:postgres@localhost:5432/webjs_test
run: node --test test/pg/pg-roundtrip.test.mjs
browser:
name: Browser (web-test-runner / Playwright)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
- name: Install Playwright browsers (Chromium, Firefox, WebKit)
run: npx playwright install --with-deps chromium firefox webkit
- run: npm run test:browser
e2e:
name: E2E (Puppeteer against the blog example)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
- name: Install Chromium for Puppeteer
run: npx playwright install --with-deps chromium
- name: Resolve the Chromium binary path
run: echo "CHROMIUM_PATH=$(node -e "console.log(require('playwright-core').chromium.executablePath())")" >> "$GITHUB_ENV"
- name: Prepare the blog example database
working-directory: examples/blog
run: |
cp .env.example .env
npm run db:migrate
npm run db:seed
- name: Run e2e
env:
WEBJS_E2E: '1'
run: node --test test/e2e/e2e.test.mjs
# Browser-test harness (#806): spawns real wtr with the shipped scaffold
# config against a fixture app whose browser test imports a real .ts
# component that imports a 'use server' action, and asserts it loads in
# Chromium. Verifies webjs test --browser end to end.
- name: Run browser-harness e2e (#806)
env:
WEBJS_E2E: '1'
run: node --test test/e2e/browser-harness.test.mjs
# Touch-emulation e2e for interactive Tier-2 ui components (#745/#747):
# boots the site serving the gallery and taps hover-card / dropdown-submenu / sonner
# under a Chromium iPhone context (faithful touch events, no real device).
- name: Run ui touch e2e
run: npm run test:e2e:touch --workspace=@webjsdev/ui
# Cross-runtime e2e (#523), split into its OWN job (#774) so it runs in
# PARALLEL with the Node-served e2e above instead of as a trailing step
# (which serialized the two and ~doubled the e2e critical path). Re-runs the
# SAME suite under node --test (its node:test hook lifecycle does not survive
# `bun test`) but with the blog SERVED on Bun (WEBJS_E2E_RUNTIME=bun spawns
# the blog under the bun binary), proving the Bun.serve shell + Drizzle-on-Bun
# in a real browser. The few node-only assertions (SSR seeding, #472/#488) and
# the #528-blocked abort test skip themselves on Bun.
#
# NOTE: the required-status-check gate on `main` is the Node job above
# ("E2E (Puppeteer against the blog example)"); this Bun job is an additional
# parallel signal. To also gate merges on it, add its name to
# branches/main/protection required_status_checks (admin op, see
# scripts/protect-main.sh).
e2e-bun:
name: E2E (blog served on Bun)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
- name: Install Chromium for Puppeteer
run: npx playwright install --with-deps chromium
- name: Resolve the Chromium binary path
run: echo "CHROMIUM_PATH=$(node -e "console.log(require('playwright-core').chromium.executablePath())")" >> "$GITHUB_ENV"
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Prepare the blog example database
working-directory: examples/blog
run: |
cp .env.example .env
npm run db:migrate
npm run db:seed
- name: Run e2e with the blog served on Bun
env:
WEBJS_E2E: '1'
WEBJS_E2E_RUNTIME: 'bun'
run: node --test test/e2e/e2e.test.mjs
dist:
name: Build (@webjsdev/core dist)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
# The published @webjsdev/core ships pre-built dist/ bundles (built
# by the prepare hook at publish). Build them in CI so a bundling
# break is caught on the PR, not at release time.
- run: npm run build:dist --workspace=@webjsdev/core
apps:
name: In-repo app tests (all four apps)
runs-on: ubuntu-latest
# The framework jobs above cover packages/* and the root cross-package
# suite. This job runs each IN-REPO app's OWN test suite (its `webjs test`
# script), which the root runners do not discover, so a regression in an
# app's tests gates the merge (issue #342). The website's `test` runs both
# its node + browser suites (hence Playwright), and covers the docs too
# since they are its own /docs routes; the blog is node-only and touches
# its SQLite DB (the same setup the unit + e2e jobs do). The website's
# suite also covers the component gallery, which is its own /ui routes
# since #1099. The `node test/bun/app-boot.mjs` boot-check step below
# (#627) additionally boots it via createRequestHandler and asserts it
# serves real routes with no broken modulepreload; the same script runs on
# Bun in the `bun` job (#542).
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
- run: npm ci
- name: Install Playwright Chromium (for the website browser tests)
run: npx playwright install --with-deps chromium
- name: Prepare the blog example database
working-directory: examples/blog
run: |
cp .env.example .env
npm run db:migrate
npm run db:seed
- name: website tests (node + browser)
run: npm test --workspace=@webjsdev/website
- name: blog tests (node)
run: npm test --workspace=@webjsdev/example-blog
# Boot the website on Node and assert it serves real routes with no
# broken modulepreload, covering its /docs (#1098) and /ui (#1099)
# routes. Runs the app's `webjs.start.before` presteps first (the ui
# registry copy, the #526 root cause). The blog is covered by the e2e
# job. The docs.webjs.dev and ui.webjs.dev redirect hosts are not here on
# purpose: every route on them is an empty 301, which would pass
# vacuously; they are covered by test/docs/docs-host-redirect.test.mjs
# and test/ui/ui-host-redirect.test.mjs.
- name: App boot-check on Node (website incl. /docs + /ui)
run: node test/bun/app-boot.mjs
docker:
name: Docker image build (the deploy artifact)
runs-on: ubuntu-latest
# The jobs above run the apps in-process via createRequestHandler; NONE of
# them build the Docker image that every Railway service actually deploys.
# That gap let #404's package reorg ship a stale `COPY packages/ts-plugin`
# path that broke all four live deploys for a day (#409): a COPY of a
# missing source is a hard Docker error, invisible to every in-process
# check. Building the image here catches that class (stale COPY paths, a
# broken npm install / dist / tailwind / db-migrate step in the image) on the
# PR instead of at deploy time. Build only, no push.
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v3
- name: Build the monorepo image
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max