fix(server): wrap exported functions as hoisted declarations in action seed facade (#1208) #2610
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |