27 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5 9671670269 fix(ci): glob test files instead of passing directories to node --test
ci / test (24) (push) Successful in 35s
ci / test (26) (push) Successful in 31s
ci / e2e (push) Successful in 1m5s
release / publish (push) Successful in 35s
Node 24's test runner spawns directory arguments as entry modules
(MODULE_NOT_FOUND); Node 26 accepts them. Shell-expanded file globs work
on both — found by the first real CI run on the Gitea runner, where the
Node 24 matrix leg and the release gate failed while 26 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 21:46:42 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c5f297a3b7 release: 8.0.0
ci / test (24) (push) Failing after 32s
ci / test (26) (push) Successful in 33s
ci / e2e (push) Failing after 1m6s
release / publish (push) Failing after 33s
All workspace packages to 8.0.0; inter-package ranges tightened from *
to ^8.0.0 for publication.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 21:35:15 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 af482330e9 fix(bankai): survive real-world CSS and serve real media types
ci / test (24) (push) Failing after 1m55s
ci / test (26) (push) Successful in 35s
ci / e2e (push) Failing after 1m8s
Lightning CSS refuses IE-era hacks (*zoom and friends) that established
libraries like tachyons still carry; enable errorRecovery so bankai
strips them instead of failing the build — a zero-config tool ingesting
decade-old CSS should shrug, not crash. Found migrating an actual choo
v7 production site. Also: proper MIME types for avif/jpg/gif/mp4/woff in
the static server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 20:03:09 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ce1ec9e4a9 rebrand: buuh — a friendly public fork under the uhhm org
Packages renamed to the @uhhm scope (@uhhm/buuh, @uhhm/buuh-html,
@uhhm/buuh-component, @uhhm/buuh-devtools, @uhhm/buuh-migrate,
@uhhm/bankai). Scoping is load-bearing twice over: npm routes registries
per scope so @uhhm/* resolves against project.uhhm.no while everything
else stays on npmjs, and it means this fork never squats upstream's
names anywhere. The codemod now migrates choo v7 apps to the @uhhm
names. README rewritten with the fork framing and full upstream credit;
the choojs RFC moves to docs/upstream-rfc-draft.md, in the drawer for if
this work ever goes home. API unchanged — choo() is still choo().

Also: Gitea Actions CI + release workflows (npm publish to the uhhm
registry on tag push, CDN bundle uploaded as a generic package),
npm run bundle producing dist-cdn/buuh.js (the whole framework as one
minified ES module for import-map use), docs/publishing.md explaining
what Gitea Packages is (a real npm registry) and is not (a CDN — serve
the bundle from a static host with module-safe MIME instead), and
onload.js constructing window.MutationObserver to match its own guard
(surfaced by smoke-testing the bundle outside a full browser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:55:20 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 b957b02410 docs: Phase 5 — v8 README, migration guide, deploy recipes, RFC draft
README rewritten for the v8 branch: honest size claim (7.97 kB min+gzip
for the whole framework, with the v7 '4kb' context), the zero-build
story up front, bankai v10, package map, credits to yoshuawuyts, the
choojs contributors and the pirxpilot fork line.

docs/migrating-v7-to-v8.md: the codemod path, the specifier map, and
every deliberate behavior change spelled out. docs/deploy.md: plain
Node, Docker, proxy/CDN — including the HTTP/3 answer (h3 is
infrastructure's job; bankai's contract is the 103 + Link headers that
any hints-aware edge, h3 included, propagates; server push is dead
everywhere and never existed in h3) — and web-standard runtimes.

docs/rfc.md: the draft announcement for choojs/choo — continuation
framing, pings to yoshuawuyts and pirxpilot, the npm-rights ask, API
feedback questions, and a three-week comment window with a
silence-is-consent close.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:41:22 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 99f9da1e3b feat(bankai): Phase 4 follow-ups — css convention, live titles, prerender, HTTP/2, size budget
- style.css convention: <entry dir>/style.css is imported by the virtual
  client entry (CSS is a client concern; server code never sees it — the
  v8 answer to sheetify), extracted and hashed by Vite, stylesheet-linked
  in the head and preloaded via Early Hints. Counter example styled.
- SSR <title> from state: the server reads the first stream chunk before
  writing the head, by which point stores and the first render slice have
  run — DOMTitleChange emits land in the document title. Counter emits.
- bankai build --prerender /,/about renders routes through the same
  toStream path to static <route>/index.html (precompressed, precached
  by the service worker, served with Early Hints).
- bankai serve --h2: HTTP/2 with a generated local cert (openssl,
  cached; allowHTTP1) — browsers only act on Early Hints over h2/h3.
  Tested with a real h2 client observing the 103 interim response.
- npm run size: the framework wire-size budget, enforced in CI. Whole
  framework (core + html engine + morph + hydrate) is 7.97 kB min+gzip /
  7.15 kB brotli; budget 8.5 kB with the v7 '4kb' context documented
  (that number excluded the html engine, which lived in the browserify
  transform).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:39:25 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 0a351d7f33 docs: Phase 4 complete — bankai v10
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:32:38 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 8b684a17d2 feat(examples)+test(e2e): counter service worker; bankai dev+prod in Chromium
The counter example gains a sw.js exercising bankai's injected-precache
build. e2e proves both bankai paths in real Chromium: the built app
serves, hydrates and runs with zero console errors and zero mismatch
warnings (and choo consumed window.initialState), and the dev server's
Vite-transformed virtual entry hydrates the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:32:37 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 7e08b585ac feat(bankai): bankai v10 — the isomorphic compiler & server on Vite 8
One command, zero config, and only the client ever bundles: v8 server
code is plain ESM that Node runs as-authored, so there is no server
build to rot (the lesson of v9).

- start: Vite middleware mode + HMR with per-request streaming SSR via
  ssrLoadModule; a virtual client entry generates the browser glue so
  the user writes exactly one isomorphic module (plan decision D5).
- build: client bundle via Vite 8/Rolldown, manifest-derived route
  assets in dist/bankai.json, service worker built with the precache
  list injected (choo-service-worker convention, manifest edition),
  brotli+gzip precompression of every text asset.
- serve: immutable caching + precompressed negotiation for hashed
  assets, 103 Early Hints (res.writeEarlyHints) with the route's assets
  before every page, streaming SSR, and a window.initialState tail with
  script-breakout-safe serialization and choo internals filtered out.
- inspect: raw/gzip/brotli size report.

Integration tests drive the real counter example through build and
serve, asserting the 103 interim response at the HTTP level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:32:37 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d755af5ca0 fix(html): hydration mismatch detection ignores <script> elements
Server pages legitimately carry scripts (state serialization, analytics)
that are spent by hydration time and that client views never render —
they are noise to the detector, and morph dropping them is harmless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 19:32:37 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 ce8fedee0b docs: Phase 3 complete — toStream, prefetch, lazy routes, devtools, codemod
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 18:57:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c101ee464a feat(migrate): add @choojs/migrate — the choo-migrate v7→v8 codemod
Regex-based on purpose: converts simple top-level CJS patterns to ESM,
remaps package specifiers to their @choojs homes, points retired nano*
packages at built-in replacements (choo-lazy-route → lazy()), and reports
everything it refuses to guess at instead of guessing. Validated against
choo's own v7 example app: migrated output runs on v8 unmodified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 18:57:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 fd1d05cea0 feat(devtools): add @choojs/devtools — window.choo console tooling
Carries forward the choo-devtools 3.x essentials: live state handle,
emit-from-console, event log, nanotiming measures via PerformanceObserver,
state copy(), help(). No-op on the server; verbose logging opt-in via
localStorage.CHOO_DEVTOOLS_VERBOSE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 18:57:51 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 d22f996596 feat(examples)+test(e2e): streaming SSR demo, proven progressive in Chromium
examples/streaming is a runnable Node server piping toStream() through
Readable.fromWeb. The e2e server gains /stream, and two tests prove real
streaming: shell bytes arrive over HTTP before the async hole resolves,
and Chromium builds the shell DOM mid-stream, then grows the slow section
into the same document without navigation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 18:44:52 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 22c5045c25 feat(core): toStream() and lazy() routes — the answer to choojs/choo#653
toStream(location, state) renders to a web-standard ReadableStream of
UTF-8 bytes: pass it to new Response() on web servers or
Readable.fromWeb().pipe(res) on Node. It awaits store prefetch promises
(state.prefetch, the pattern the #653 thread wanted standardized) and
lazy route views before rendering, then flushes template output
progressively through async holes.

lazy(loader, loadingView?) wraps a dynamic import as a route handler:
loads once, caches forever, renders the loading view (or holds the
current tree via a placeholder) while in flight, emits render on arrival.
toString() refuses lazy routes and prefetching stores with pointers to
toStream() — both halves of the async-route story ship together, unlike
the original PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 18:43:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 9cf9e51ac9 feat(html): streamable server templates — async holes in child position
The server tag now builds a parts list (string runs + async holes) instead
of an eager string. Sync templates collapse to one string and toString()
is byte-identical to before. Promises and async iterables in child
position make the result streamable: iterating it yields everything before
a hole immediately, then each hole as it resolves, in document order —
resolved values get full child semantics (templates, arrays, raw,
escaping). toString() on async content throws with streaming guidance;
async values in attribute position throw immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 18:43:40 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c9f5ba2fda bench: @choojs/html vs nanohtml v1 vs µhtml v5, happy-dom + real Chromium
npm run bench (happy-dom, all three engines) and npm run bench:browser
(Playwright Chromium, the two ESM engines). Honest numbers recorded in
docs/v8.md: we create ~12% faster than µhtml in real Chromium (the
parse-once/clone design), µhtml updates in place ~5x faster than
fresh-tree + nanomorph (choo's architectural cost, mitigated by component
caching), and server strings are on par with nanohtml v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 17:59:53 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 63b56144a5 test(e2e): real-browser pass in Playwright Chromium
Three end-to-end scenarios against a static+SSR test server: the
zero-build page (import map + native ESM, no bundler), the SSR page
(rendered content in the raw response, live after hydration, no mismatch
warnings), and adoption (an expando on the server-rendered node survives
a real render). Renders are raf-batched so assertions poll.

Real Chromium flushed out three fixes happy-dom couldn't see:
- nanoraf called an extracted requestAnimationFrame bare — Illegal
  invocation under strict-mode ESM (sloppy CJS had masked it); wrapped.
- the counter example only routed '/', so serving it from any subpath
  threw; it now has a wildcard fallback.
- hydration mismatch detection is now whitespace-insensitive (the parser
  reparents whitespace, e.g. text after </body>), and page scripts belong
  in <head> when a view owns <body> — same convention bankai v9 used.

CI gets an e2e job with chromium-headless-shell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 17:57:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 13155a3469 feat(html): adoption-style hydration with mismatch reporting
hydrate(oldNode, newNode) normalizes the client tree to parser text-node
granularity, walks both trees to report the first server/client markup
disagreement (node, text, attribute, or child count, with a path), then
morphs — matching nodes are adopted in place, the client render wins.
choo.mount() now hydrates and console.warns on mismatch.

Building this surfaced two real isomorphism divergences, both fixed:
adjacent text nodes from template holes vs the parser's merged runs
(folded via Node.normalize), and the server serializing onclick="" where
the browser sets a property — event handlers now leave no trace in server
markup at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 17:49:32 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 37bd8adf81 docs: record the async-route (choo#653) design as a Phase 3 deliverable
Deliberately deferred so the SSR half (toStream) ships together with the
browser half — the missing server story is what stalled the original PR.
API shape goes to the RFC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 17:44:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 870aa79668 test+docs: full-app browser integration test and the isomorphic counter example
The integration test runs the counter through the real browser path in
happy-dom: start(), store events, raf-batched morph, DOM click handlers,
emit coalescing. examples/counter is the Phase 2 exit criterion: one app
module mounted zero-build in a browser via import map and string-rendered
by node examples/counter/render.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 16:56:54 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 c696e00d4f feat(component): add @choojs/component — nanocomponent + on-load as ES classes
Ported from nanocomponent 6.6.0 and on-load 3.4.1 (MIT) with attribution:
render/update/morph lifecycle, proxy nodes with isSameNode identity, and
the MutationObserver-driven load/unload hooks. This class is the intended
island/hydration boundary for later phases. @choojs/core exposes ./timing
as a subpath for the instrumentation calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 16:56:54 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 84230b1770 feat(html): browser renderer — runtime-cached tagged templates
Each template literal parses once, keyed by its strings array in a
WeakMap: static parts become a <template> with hole markers, renders
clone and fill. Interpolated values never pass through innerHTML (child
values become text nodes or adopted DOM; attributes go through
setAttribute). Event handlers set as properties so nanomorph copies them.
Document-level roots (<body>/<head>/<html>) parse via DOMParser because
<template> drops them. Replaces the v7 browserify transform entirely —
production speed is now a runtime property.

Tests run in happy-dom (new devDependency), including a check that server
and browser renderers serialize identical markup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 16:56:53 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 501e8f5927 fix(core): URL normalization that survives non-ASCII and malformed input
Replace nanorouter's regex+decodeURI normalization with the WHATWG URL
parser. Path segments are percent-decoded exactly once in the trie, with
a keep-raw fallback instead of the URIError that crashed v7 on a literal
'%'. Route definitions and locations are NFC-normalized so 'café' matches
regardless of composition. Hash-to-slash rewriting now handles every hash,
not just the first, and state.href is decoded for humans (raw on failure).

Behavior changes from v7: params are no longer double-decoded (%2540 →
'%40', not '@'), and unmatchable encodings fall back to raw segments
instead of silently 404ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 16:50:34 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 4ad3d02207 feat(core): add @choojs/core — ESM port of choo with consolidated nano* internals
Same public API as choo 7.1.0 (use/route/start/mount/toString/emit; choo()
callable with or without new, Choo exported). nanobus, nanorouter+wayfarer,
nanolru+component cache, nanoraf, nanohref, nanotiming, document-ready and
scroll-to-anchor are ported into lib/ with per-file attribution; nanoquery
is replaced by URLSearchParams. The v7 node test suite is ported from tape
to node:test and passes unchanged in behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 15:59:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 90cca611c9 feat(html): add @choojs/html — server renderer, raw(), morph
ESM ports with attribution: nanohtml 1.10.0 server tag (browserify/babel
transform branches removed — pure runtime), raw-server, and nanomorph 5.4.3
consolidated into one module. browser.js is a Phase 2 placeholder that
throws loudly. Covered by test/server.test.js on node:test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 15:59:26 +02:00
Bendik Aagaard LynghaugandClaude Fable 5 3d6b474621 chore: scaffold v8 monorepo — npm workspaces, node:test, GitHub Actions CI
The v7 source at the repo root is untouched; v8 work lives in packages/.
See docs/v8.md for layout and status against the modernization plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NgfSjHE11oFpoSnLVKLXd
2026-09-08 15:59:26 +02:00
77 changed files with 6595 additions and 725 deletions
+32
View File
@@ -0,0 +1,32 @@
name: ci
on:
push:
branches: [main, master, v8]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [24, 26]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm install
- run: npm test
- run: npm run size
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install
- run: npx playwright install --with-deps chromium-headless-shell
- run: npm run test:e2e
+44
View File
@@ -0,0 +1,44 @@
name: release
# Tag a version (git tag v8.0.0 && git push --tags) and this publishes
# every workspace package to the uhhm npm registry on this Gitea, plus
# the single-file CDN bundle as a generic package.
#
# Repo secrets needed (Settings → Actions → Secrets):
# PACKAGES_TOKEN — a Gitea access token with package:write scope
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install
- run: npm test
- name: point npm at the uhhm registry
run: |
echo "@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/" >> "$HOME/.npmrc"
echo "//project.uhhm.no/api/packages/uhhm/npm/:_authToken=${{ secrets.PACKAGES_TOKEN }}" >> "$HOME/.npmrc"
- name: publish workspaces
run: npm publish --workspaces
- name: build + upload the CDN bundle (generic package)
run: |
npm run bundle
VERSION="${GITHUB_REF_NAME#v}"
curl --fail -X PUT \
-H "Authorization: token ${{ secrets.PACKAGES_TOKEN }}" \
--upload-file dist-cdn/buuh.js \
"https://project.uhhm.no/api/packages/uhhm/generic/buuh-cdn/${VERSION}/buuh.js"
curl --fail -X PUT \
-H "Authorization: token ${{ secrets.PACKAGES_TOKEN }}" \
--upload-file dist-cdn/buuh.js.map \
"https://project.uhhm.no/api/packages/uhhm/generic/buuh-cdn/${VERSION}/buuh.js.map"
+32
View File
@@ -0,0 +1,32 @@
name: ci
on:
push:
branches: [master, v8]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [24, 26]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm install
- run: npm test
- run: npm run size
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install
- run: npx playwright install --with-deps chromium-headless-shell
- run: npm run test:e2e
+1
View File
@@ -12,3 +12,4 @@ coverage.json
package-lock.json
yarn.lock
.idea
dist-cdn/
+118 -656
View File
@@ -1,701 +1,163 @@
<h1 align="center">Choo</h1>
<h1 align="center">buuh</h1>
<div align="center">
:steam_locomotive::train::train::train::train::train:
<strong>🚂🚋🚋🚋🚋👻</strong>
</div>
<div align="center">
<strong>Fun functional programming</strong>
</div>
<div align="center">
A <code>4kb</code> framework for creating sturdy frontend applications
The sturdy little frontend framework — a friendly fork of choo,
rebuilt for the modern platform.
</div>
<br />
<br>
<div align="center">
<!-- Stability -->
<a href="https://nodejs.org/api/documentation.html#documentation_stability_index">
<img src="https://img.shields.io/badge/stability-experimental-orange.svg?style=flat-square"
alt="API stability" />
</a>
<!-- NPM version -->
<a href="https://npmjs.org/package/choo">
<img src="https://img.shields.io/npm/v/choo.svg?style=flat-square"
alt="NPM version" />
</a>
<!-- Build Status -->
<a href="https://travis-ci.org/choojs/choo">
<img src="https://img.shields.io/travis/choojs/choo/master.svg?style=flat-square"
alt="Build Status" />
</a>
<!-- Test Coverage -->
<a href="https://codecov.io/github/choojs/choo">
<img src="https://img.shields.io/codecov/c/github/choojs/choo/master.svg?style=flat-square"
alt="Test Coverage" />
</a>
<!-- Downloads -->
<a href="https://npmjs.org/package/choo">
<img src="https://img.shields.io/npm/dt/choo.svg?style=flat-square"
alt="Download" />
</a>
<!-- Standard -->
<a href="https://standardjs.com">
<img src="https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square"
alt="Standard" />
</a>
</div>
> **buuh is a fork of [choo](https://github.com/choojs/choo)** — same
> API, same philosophy, modern engine. It exists because we love choo
> and wanted it alive on today's platform; all credit for the design
> belongs upstream (see [Credits](#credits)). Everything is MIT, and if
> upstream ever wants this work home, the door is open
> ([`docs/upstream-rfc-draft.md`](docs/upstream-rfc-draft.md)).
<div align="center">
<h3>
<a href="https://choo.io">
Website
</a>
<span> | </span>
<a href="https://github.com/choojs/choo-handbook">
Handbook
</a>
<span> | </span>
<a href="https://github.com/YerkoPalma/awesome-choo">
Ecosystem
</a>
<span> | </span>
<!-- <a href="https://github.com/trainyard/choo-cli"> -->
<!-- CLI -->
<!-- </a> -->
<!-- <span> | </span> -->
<a href="https://github.com/choojs/choo/blob/master/.github/CONTRIBUTING.md">
Contributing
</a>
<span> | </span>
<a href="https://www.reddit.com/r/choojs/">
Reddit
</a>
<span> | </span>
<a href="https://webchat.freenode.net/?channels=choo">
Chat
</a>
</h3>
</div>
## What is this
<div align="center">
<sub>The little framework that could. Built with ❤︎ by
<a href="https://twitter.com/yoshuawuyts">Yoshua Wuyts</a> and
<a href="https://github.com/choojs/choo/graphs/contributors">
contributors
</a>
</div>
buuh is a small framework for building frontend applications with plain
JavaScript and HTML. State lives in stores, changes flow through an event
emitter, views are tagged template literals, and re-renders morph the real
DOM. The whole framework — core, html engine, morphing, hydration, router,
event bus — is **7.97 kB min+gzip** (7.15 kB brotli), enforced by CI.
## Table of Contents
- [Features](#features)
- [Example](#example)
- [Philosophy](#philosophy)
- [Events](#events)
- [State](#state)
- [Routing](#routing)
- [Server Rendering](#server-rendering)
- [Components](#components)
- [Optimizations](#optimizations)
- [FAQ](#faq)
- [API](#api)
- [Installation](#installation)
- [See Also](#see-also)
- [Support](#support)
## Features
- __minimal size:__ weighing `4kb`, Choo is a tiny little framework
- __event based:__ our performant event system makes writing apps easy
- __small api:__ with only 6 methods there's not much to learn
- __minimal tooling:__ built for the cutting edge `browserify` compiler
- __isomorphic:__ renders seamlessly in both Node and browsers
- __very cute:__ choo choo!
## Example
```js
var html = require('choo/html')
var devtools = require('choo-devtools')
var choo = require('choo')
import choo from '@uhhm/buuh'
import html from '@uhhm/buuh-html'
var app = choo()
app.use(devtools())
app.use(countStore)
app.route('/', mainView)
app.mount('body')
const app = choo()
function mainView (state, emit) {
return html`
<body>
<h1>count is ${state.count}</h1>
<button onclick=${onclick}>Increment</button>
</body>
`
function onclick () {
emit('increment', 1)
}
}
function countStore (state, emitter) {
app.use((state, emitter) => {
state.count = 0
emitter.on('increment', function (count) {
state.count += count
emitter.on('increment', (n) => {
state.count += n
emitter.emit('render')
})
}
```
Want to see more examples? Check out the [Choo handbook][handbook].
## Philosophy
We believe programming should be fun and light, not stern and stressful. It's
cool to be cute; using serious words without explaining them doesn't make for
better results - if anything it scares people off. We don't want to be scary,
we want to be nice and fun, and then _casually_ be the best choice around.
_Real casually._
We believe frameworks should be disposable, and components recyclable. We don't
want a web where walled gardens jealously compete with one another. By making
the DOM the lowest common denominator, switching from one framework to another
becomes frictionless. Choo is modest in its design; we don't believe it will
be top of the class forever, so we've made it as easy to toss out as it is to
pick up.
We don't believe that bigger is better. Big APIs, large complexities, long
files - we see them as omens of impending userland complexity. We want everyone
on a team, no matter the size, to fully understand how an application is laid
out. And once an application is built, we want it to be small, performant and
easy to reason about. All of which makes for easy to debug code, better results
and super smiley faces.
## Events
At the core of Choo is an event emitter, which is used for both application
logic but also to interface with the framework itself. The package we use for
this is [nanobus](https://github.com/choojs/nanobus).
You can access the emitter through `app.use(state, emitter, app)`, `app.route(route,
view(state, emit))` or `app.emitter`. Routes only have access to the
`emitter.emit` method to encourage people to separate business logic from
render logic.
The purpose of the emitter is two-fold: it allows wiring up application code
together, and splitting it off nicely - but it also allows communicating with
the Choo framework itself. All events can be read as constants from
`state.events`. Choo ships with the following events built in:
### `'DOMContentLoaded'`|`state.events.DOMCONTENTLOADED`
Choo emits this when the DOM is ready. Similar to the DOM's
`'DOMContentLoaded'` event, except it will be emitted even if the listener is
added _after_ the DOM became ready. Uses
[document-ready](https://github.com/bendrucker/document-ready) under the hood.
### `'render'`|`state.events.RENDER`
This event should be emitted to re-render the DOM. A common pattern is to
update the `state` object, and then emit the `'render'` event straight after.
Note that `'render'` will only have an effect once the `DOMContentLoaded` event
has been fired.
### `'navigate'`|`state.events.NAVIGATE`
Choo emits this event whenever routes change. This is triggered by either
`'pushState'`, `'replaceState'` or `'popState'`.
### `'pushState'`|`state.events.PUSHSTATE`
This event should be emitted to navigate to a new route. The new route is added
to the browser's history stack, and will emit `'navigate'` and `'render'`.
Similar to
[history.pushState](http://devdocs.io/dom/history_api).
### `'replaceState'`|`state.events.REPLACESTATE`
This event should be emitted to navigate to a new route. The new route replaces
the current entry in the browser's history stack, and will emit `'navigate'`
and `'render'`. Similar to
[history.replaceState](http://devdocs.io/dom/history#history-replacestate).
### `'popState'`|`state.events.POPSTATE`
This event is emitted when the user hits the 'back' button in their browser.
The new route will be a previous entry in the browser's history stack, and
immediately afterward the`'navigate'` and `'render'`events will be emitted.
Similar to [history.popState](http://devdocs.io/dom_events/popstate). (Note
that `emit('popState')` will _not_ cause a popState action - use
`history.go(-1)` for that - this is different from the behaviour of `pushState`
and `replaceState`!)
### `'DOMTitleChange'`|`state.events.DOMTITLECHANGE`
This event should be emitted whenever the `document.title` needs to be updated.
It will set both `document.title` and `state.title`. This value can be used
when server rendering to accurately include a `<title>` tag in the header.
This is derived from the
[DOMTitleChanged event](https://developer.mozilla.org/en-US/docs/Web/Events/DOMTitleChanged).
## State
Choo comes with a shared state object. This object can be mutated freely, and
is passed into the view functions whenever `'render'` is emitted. The state
object comes with a few properties set.
When initializing the application, `window.initialState` is used to provision
the initial state. This is especially useful when combined with server
rendering. See [server rendering](#server-rendering) for more details.
### `state.events`
A mapping of Choo's built in events. It's recommended to extend this object
with your application's events. By defining your event names once and setting
them on `state.events`, it reduces the chance of typos, generally autocompletes
better, makes refactoring easier and compresses better.
### `state.params`
The current params taken from the route. E.g. `/foo/:bar` becomes available as
`state.params.bar` If a wildcard route is used (`/foo/*`) it's available as
`state.params.wildcard`.
### `state.query`
An object containing the current queryString. `/foo?bin=baz` becomes `{ bin:
'baz' }`.
### `state.href`
An object containing the current href. `/foo?bin=baz` becomes `/foo`.
### `state.route`
The current name of the route used in the router (e.g. `/foo/:bar`).
### `state.title`
The current page title. Can be set using the `DOMTitleChange` event.
### `state.components`
An object _recommended_ to use for local component state.
### `state.cache(Component, id, [...args])`
Generic class cache. Will lookup Component instance by id and create one if not
found. Useful for working with stateful [components](#components).
## Routing
Choo is an application level framework. This means that it takes care of
everything related to routing and pathnames for you.
### Params
Params can be registered by prepending the route name with `:routename`, e.g.
`/foo/:bar/:baz`. The value of the param will be saved on `state.params` (e.g.
`state.params.bar`). Wildcard routes can be registered with `*`, e.g. `/foo/*`.
The value of the wildcard will be saved under `state.params.wildcard`.
### Default routes
Sometimes a route doesn't match, and you want to display a page to handle it.
You can do this by declaring `app.route('*', handler)` to handle all routes
that didn't match anything else.
### Querystrings
Querystrings (e.g. `?foo=bar`) are ignored when matching routes. An object
containing the key-value mappings exists as `state.query`.
### Hash routing
By default, hashes are ignored when routing. When enabling hash routing
(`choo({ hash: true })`) hashes will be treated as part of the url, converting
`/foo#bar` to `/foo/bar`. This is useful if the application is not mounted at
the website root. Unless hash routing is enabled, if a hash is found we check if
there's an anchor on the same page, and will scroll the element into view. Using
both hashes in URLs and anchor links on the page is generally not recommended.
### Following links
By default all clicks on `<a>` tags are handled by the router through the
[nanohref](https://github.com/choojs/nanohref) module. This can be
disabled application-wide by passing `{ href: false }` to the application
constructor. The event is not handled under the following conditions:
- the click event had `.preventDefault()` called on it
- the link has a `target="_blank"` attribute with `rel="noopener noreferrer"`
- a modifier key is enabled (e.g. `ctrl`, `alt`, `shift` or `meta`)
- the link's href starts with protocol handler such as `mailto:` or `dat:`
- the link points to a different host
- the link has a `download` attribute
:warn: Note that we only handle `target=_blank` if they also have
`rel="noopener noreferrer"` on them. This is needed to [properly sandbox web
pages](https://mathiasbynens.github.io/rel-noopener/).
### Navigating programmatically
To navigate routes you can emit `'pushState'`, `'popState'` or
`'replaceState'`. See [#events](#events) for more details about these events.
## Server Rendering
Choo was built with Node in mind. To render on the server call
`.toString(route, [state])` on your `choo` instance.
```js
var html = require('choo/html')
var choo = require('choo')
var app = choo()
app.route('/', function (state, emit) {
return html`<div>Hello ${state.name}</div>`
})
var state = { name: 'Node' }
var string = app.toString('/', state)
app.route('/', (state, emit) => html`
<body>
<h1>count is ${state.count}</h1>
<button onclick=${() => emit('increment', 1)}>Increment</button>
</body>
`)
console.log(string)
// => '<div>Hello Node</div>'
app.mount('body')
```
When starting an application in the browser, it's recommended to provide the
same `state` object available as `window.initialState`. When the application is
started, it'll be used to initialize the application state. The process of
server rendering, and providing an initial state on the client to create the
exact same document is also known as "rehydration".
## Getting it
For security purposes, after `window.initialState` is used it is deleted from
the `window` object.
From the uhhm registry (one `.npmrc` line routes the scope; everything
else still resolves from npmjs):
```console
$ echo "@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/" >> .npmrc
$ npm i @uhhm/buuh @uhhm/buuh-html
$ npm i -D @uhhm/bankai
```
Or with no package manager at all — one file, one import map:
```html
<html>
<head>
<script>window.initialState = { initial: 'state' }</script>
</head>
<body>
</body>
</html>
<script type="importmap">
{ "imports": { "buuh": "https://cdn.uhhm.no/buuh@8.0.0.js" } }
</script>
<script type="module">
import { choo, html } from 'buuh'
</script>
```
## Components
From time to time there will arise a need to have an element in an application
hold a self-contained state or to not rerender when the application does. This
is common when using 3rd party libraries to e.g. display an interactive map or a
graph and you rely on this 3rd party library to handle modifications to the DOM.
Components come baked in to Choo for these kinds of situations. See
[nanocomponent][nanocomponent] for documentation on the component class.
See [`docs/publishing.md`](docs/publishing.md) for how the registry and
the CDN bundle fit together.
```javascript
// map.js
var html = require('choo/html')
var mapboxgl = require('mapbox-gl')
var Component = require('choo/component')
## Why this fork
module.exports = class Map extends Component {
constructor (id, state, emit) {
super(id)
this.local = state.components[id] = {}
}
- **No build step, anywhere.** Templates parse at runtime (cached per
call site in a WeakMap, clone-based instantiation — as fast as choo's
old compile-time transform, without the toolchain). An import map and
a `<script type="module">` is a complete development setup:
see [`examples/counter/index.html`](examples/counter/index.html).
- **Isomorphic by contract.** One app module. The browser mounts it, the
server imports the same file and calls `toString(route)` or streams it
with `toStream(route)` — a web-standard `ReadableStream`, so it plugs
into Node, Deno, Bun, and edge runtimes alike.
- **Streaming SSR.** Async values in child position flush the shell
first and stream the rest in document order. Store data via
`state.prefetch` promises. Hydration *adopts* the server DOM (same
element references, form state kept) and warns precisely when server
and client markup disagree.
- **Async routes.** `app.route('/big', lazy(() => import('./big.js')))`
— loading view or held tree while in flight, awaited on the server
(the answer to [choojs/choo#653](https://github.com/choojs/choo/pull/653)).
- **Modern platform, no shims.** ESM only, Node ≥ 24, Baseline
Widely Available browsers. The router speaks WHATWG URL and survives
emoji, literal `%`, and Unicode normalization differences.
load (element) {
this.map = new mapboxgl.Map({
container: element,
center: this.local.center
})
}
## bankai
update (center) {
if (center.join() !== this.local.center.join()) {
this.map.setCenter(center)
}
return false
}
The isomorphic compiler & server, rebuilt on Vite 8 / Rolldown. Only the
client ever bundles — server code is plain ESM Node runs as-authored.
createElement (center) {
this.local.center = center
return html`<div></div>`
}
}
```console
$ bankai start app.js # dev: HMR + streaming SSR on every request
$ bankai build app.js # client bundle, manifest, service worker,
# brotli/gzip precompression, --prerender
$ bankai serve # prod: 103 Early Hints, immutable assets,
# streaming SSR, --h2
$ bankai inspect # raw/gzip/brotli size report
```
```javascript
// index.js
var choo = require('choo')
var html = require('choo/html')
var Map = require('./map.js')
Conventions over config: `style.css` next to your entry is bundled for
the client (server code never sees it), `sw.js` becomes a service worker
with the precache manifest injected, `<title>` follows your
`DOMTitleChange` emits — on the server too.
var app = choo()
app.route('/', mainView)
app.mount('body')
## Packages
function mainView (state, emit) {
return html`
<body>
<button onclick=${onclick}>Where am i?</button>
${state.cache(Map, 'my-map').render(state.center)}
</body>
`
| package | what it is |
|---|---|
| `@uhhm/buuh` | the app class: stores, router, emitter, `toString`/`toStream`, `lazy()` |
| `@uhhm/buuh-html` | tagged templates: DOM in the browser, strings/streams on the server, `morph`, `hydrate`, `raw` |
| `@uhhm/buuh-component` | stateful components with lifecycle hooks; the hydration boundary |
| `@uhhm/buuh-devtools` | `window.choo` console tooling |
| `@uhhm/buuh-migrate` | `buuh-migrate` — the choo v7 → buuh codemod |
| `@uhhm/bankai` | the compiler & server above (bin: `bankai`) |
function onclick () {
emit('locate')
}
}
## Migrating from choo v7
app.use(function (state, emitter) {
state.center = [18.0704503, 59.3244897]
emitter.on('locate', function () {
window.navigator.geolocation.getCurrentPosition(function (position) {
state.center = [position.coords.longitude, position.coords.latitude]
emitter.emit('render')
})
})
})
```console
$ npx @uhhm/buuh-migrate .
```
### Caching components
When working with stateful components, one will need to keep track of component
instances `state.cache` does just that. The component cache is a function
which takes a component class and a unique id (`string`) as its first two
arguments. Any following arguments will be forwarded to the component constructor
together with `state` and `emit`.
converts simple CJS, remaps every specifier, and tells you honestly what
it didn't dare touch. The API surface is unchanged — `choo()` still works
without `new`. See [`docs/migrating-v7-to-v8.md`](docs/migrating-v7-to-v8.md).
The default class cache is an LRU cache (using [nanolru][nanolru]), meaning it
will only hold on to a fixed amount of class instances (`100` by default) before
starting to evict the least-recently-used instances. This behavior can be
overridden with [options](#app--chooopts).
## Development
## Optimizations
Choo is reasonably fast out of the box. But sometimes you might hit a scenario
where a particular part of the UI slows down the application, and you want to
speed it up. Here are some optimizations that are possible.
### Caching DOM elements
Sometimes we want to tell the algorithm to not evaluate certain nodes (and its
children). This can be because we're sure they haven't changed, or perhaps
because another piece of code is managing that part of the DOM tree. To achieve
this `nanomorph` evaluates the `.isSameNode()` method on nodes to determine if
they should be updated or not.
```js
var el = html`<div>node</div>`
// tell nanomorph to not compare the DOM tree if they're both divs
el.isSameNode = function (target) {
return (target && target.nodeName && target.nodeName === 'DIV')
}
```console
$ npm install
$ npm test # 109 tests on node:test, zero framework deps
$ npm run test:e2e # real Chromium: hydration, streaming, bankai
$ npm run size # the wire-size budget
$ npm run bench # vs nanohtml v1 and µhtml
$ npm run bundle # the single-file CDN build
```
### Reordering lists
It's common to work with lists of elements on the DOM. Adding, removing or
reordering elements in a list can be rather expensive. To optimize this you can
add an `id` attribute to a DOM node. When reordering nodes it will compare
nodes with the same ID against each other, resulting in far fewer re-renders.
This is especially potent when coupled with DOM node caching.
Releases: bump versions, `git tag vX.Y.Z`, push the tag — Gitea Actions
tests, publishes every package to the uhhm registry, and uploads the CDN
bundle ([`.gitea/workflows/release.yml`](.gitea/workflows/release.yml)).
```js
var el = html`
<section>
<div id="first">hello</div>
<div id="second">world</div>
</section>
`
```
## Credits
### Pruning dependencies
We use the `require('assert')` module from Node core to provide helpful error
messages in development. In production you probably want to strip this using
[unassertify][unassertify].
To convert inlined HTML to valid DOM nodes we use `require('nanohtml')`. This has
overhead during runtime, so for production environments we should unwrap this
using the [nanohtml transform][nanohtml].
Setting up browserify transforms can sometimes be a bit of hassle; to make this
more convenient we recommend using [bankai build][bankai] to build your assets for production.
## FAQ
### Why is it called Choo?
Because I thought it sounded cute. All these programs talk about being
_"performant"_, _"rigid"_, _"robust"_ - I like programming to be light, fun and
non-scary. Choo embraces that.
Also imagine telling some business people you chose to rewrite something
critical for serious bizcorp using a train themed framework.
:steam_locomotive::train::train::train:
### Is it called Choo, Choo.js or...?
It's called "Choo", though we're fine if you call it "Choo-choo" or
"Chugga-chugga-choo-choo" too. The only time "choo.js" is tolerated is if /
when you shimmy like you're a locomotive.
### Does Choo use a virtual-dom?
Choo uses [nanomorph][nanomorph], which diffs real DOM nodes instead of
virtual nodes. It turns out that [browsers are actually ridiculously good at
dealing with DOM nodes][morphdom-bench], and it has the added benefit of
working with _any_ library that produces valid DOM nodes. So to put a long
answer short: we're using something even better.
### How can I support older browsers?
Template strings aren't supported in all browsers, and parsing them creates
significant overhead. To optimize we recommend running `browserify` with
[nanohtml][nanohtml] as a global transform or using [bankai][bankai] directly.
```sh
$ browserify -g nanohtml
```
### Is choo production ready?
Sure.
## API
This section provides documentation on how each function in Choo works. It's
intended to be a technical reference. If you're interested in learning choo for
the first time, consider reading through the [handbook][handbook] first
:sparkles:
### `app = choo([opts])`
Initialize a new `choo` instance. `opts` can also contain the following values:
- __opts.history:__ default: `true`. Listen for url changes through the
history API.
- __opts.href:__ default: `true`. Handle all relative `<a
href="<location>"></a>` clicks and call `emit('render')`
- __opts.cache:__ default: `undefined`. Override default class cache used by
`state.cache`. Can be a a `number` (maximum number of instances in cache,
default `100`) or an `object` with a [nanolru][nanolru]-compatible API.
- __opts.hash:__ default: `false`. Treat hashes in URLs as part of the pathname,
transforming `/foo#bar` to `/foo/bar`. This is useful if the application is
not mounted at the website root.
### `app.use(callback(state, emitter, app))`
Call a function and pass it a `state`, `emitter` and `app`. `emitter` is an instance
of [nanobus](https://github.com/choojs/nanobus/). You can listen to
messages by calling `emitter.on()` and emit messages by calling
`emitter.emit()`. `app` is the same Choo instance. Callbacks passed to `app.use()` are commonly referred to as
`'stores'`.
If the callback has a `.storeName` property on it, it will be used to identify
the callback during tracing.
See [#events](#events) for an overview of all events.
### `app.route(routeName, handler(state, emit))`
Register a route on the router. The handler function is passed `app.state`
and `app.emitter.emit` as arguments. Uses [nanorouter][nanorouter] under the
hood.
See [#routing](#routing) for an overview of how to use routing efficiently.
### `app.mount(selector)`
Start the application and mount it on the given `querySelector`,
the given selector can be a String or a DOM element.
In the browser, this will _replace_ the selector provided with the tree returned from `app.start()`.
If you want to add the app as a child to an element, use `app.start()` to obtain the tree and manually append it.
On the server, this will save the `selector` on the app instance.
When doing server side rendering, you can then check the `app.selector` property to see where the render result should be inserted.
Returns `this`, so you can easily export the application for server side rendering:
```js
module.exports = app.mount('body')
```
### `tree = app.start()`
Start the application. Returns a tree of DOM nodes that can be mounted using
`document.body.appendChild()`.
### `app.toString(location, [state])`
Render the application to a string. Useful for rendering on the server.
### `choo/html`
Create DOM nodes from template string literals. Exposes
[nanohtml](https://github.com/choojs/nanohtml). Can be optimized using
[nanohtml][nanohtml].
### `choo/html/raw`
Exposes [nanohtml/raw](https://github.com/shama/nanohtml#unescaping) helper for rendering raw HTML content.
## Installation
```sh
$ npm install choo
```
## See Also
- [bankai](https://github.com/choojs/bankai) - streaming asset compiler
- [stack.gl](http://stack.gl/) - open software ecosystem for WebGL
- [yo-yo](https://github.com/maxogden/yo-yo) - tiny library for modular UI
- [tachyons](https://github.com/tachyons-css/tachyons) - functional CSS for
humans
- [sheetify](https://github.com/stackcss/sheetify) - modular CSS bundler for
`browserify`
## Support
Creating a quality framework takes a lot of time. Unlike others frameworks,
Choo is completely independently funded. We fight for our users. This does mean
however that we also have to spend time working contracts to pay the bills.
This is where you can help: by chipping in you can ensure more time is spent
improving Choo rather than dealing with distractions.
### Sponsors
Become a sponsor and help ensure the development of independent quality
software. You can help us keep the lights on, bellies full and work days sharp
and focused on improving the state of the web. [Become a
sponsor](https://opencollective.com/choo#sponsor)
<a href="https://opencollective.com/choo/sponsor/0/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/0/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/1/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/1/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/2/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/2/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/3/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/3/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/4/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/4/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/5/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/5/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/6/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/6/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/7/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/7/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/8/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/8/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/9/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/9/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/10/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/10/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/11/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/11/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/12/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/12/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/13/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/13/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/14/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/14/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/15/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/15/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/16/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/16/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/17/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/17/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/18/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/18/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/19/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/19/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/20/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/20/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/21/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/21/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/22/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/22/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/23/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/23/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/24/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/24/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/25/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/25/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/26/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/26/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/27/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/27/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/28/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/28/avatar.svg"></a>
<a href="https://opencollective.com/choo/sponsor/29/website" target="_blank"><img src="https://opencollective.com/choo/sponsor/29/avatar.svg"></a>
### Backers
Become a backer, and buy us a coffee (or perhaps lunch?) every month or so.
[Become a backer](https://opencollective.com/choo#backer)
<a href="https://opencollective.com/choo/backer/0/website" target="_blank"><img src="https://opencollective.com/choo/backer/0/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/1/website" target="_blank"><img src="https://opencollective.com/choo/backer/1/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/2/website" target="_blank"><img src="https://opencollective.com/choo/backer/2/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/3/website" target="_blank"><img src="https://opencollective.com/choo/backer/3/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/4/website" target="_blank"><img src="https://opencollective.com/choo/backer/4/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/5/website" target="_blank"><img src="https://opencollective.com/choo/backer/5/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/6/website" target="_blank"><img src="https://opencollective.com/choo/backer/6/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/7/website" target="_blank"><img src="https://opencollective.com/choo/backer/7/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/8/website" target="_blank"><img src="https://opencollective.com/choo/backer/8/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/9/website" target="_blank"><img src="https://opencollective.com/choo/backer/9/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/10/website" target="_blank"><img src="https://opencollective.com/choo/backer/10/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/11/website" target="_blank"><img src="https://opencollective.com/choo/backer/11/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/12/website" target="_blank"><img src="https://opencollective.com/choo/backer/12/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/13/website" target="_blank"><img src="https://opencollective.com/choo/backer/13/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/14/website" target="_blank"><img src="https://opencollective.com/choo/backer/14/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/15/website" target="_blank"><img src="https://opencollective.com/choo/backer/15/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/16/website" target="_blank"><img src="https://opencollective.com/choo/backer/16/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/17/website" target="_blank"><img src="https://opencollective.com/choo/backer/17/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/18/website" target="_blank"><img src="https://opencollective.com/choo/backer/18/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/19/website" target="_blank"><img src="https://opencollective.com/choo/backer/19/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/20/website" target="_blank"><img src="https://opencollective.com/choo/backer/20/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/21/website" target="_blank"><img src="https://opencollective.com/choo/backer/21/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/22/website" target="_blank"><img src="https://opencollective.com/choo/backer/22/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/23/website" target="_blank"><img src="https://opencollective.com/choo/backer/23/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/24/website" target="_blank"><img src="https://opencollective.com/choo/backer/24/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/25/website" target="_blank"><img src="https://opencollective.com/choo/backer/25/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/26/website" target="_blank"><img src="https://opencollective.com/choo/backer/26/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/27/website" target="_blank"><img src="https://opencollective.com/choo/backer/27/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/28/website" target="_blank"><img src="https://opencollective.com/choo/backer/28/avatar.svg"></a>
<a href="https://opencollective.com/choo/backer/29/website" target="_blank"><img src="https://opencollective.com/choo/backer/29/avatar.svg"></a>
## License
[MIT](https://tldrlegal.com/license/mit-license)
[nanocomponent]: https://github.com/choojs/nanocomponent
[nanolru]: https://github.com/s3ththompson/nanolru
[bankai]: https://github.com/choojs/bankai
[nanohtml]: https://github.com/choojs/nanohtml
[browserify]: https://github.com/substack/node-browserify
[budo]: https://github.com/mattdesl/budo
[es2020]: https://github.com/yoshuawuyts/es2020
[handbook]: https://github.com/yoshuawuyts/choo-handbook
[hyperx]: https://github.com/substack/hyperx
[morphdom-bench]: https://github.com/patrick-steele-idem/morphdom#benchmarks
[nanomorph]: https://github.com/choojs/nanomorph
[nanorouter]: https://github.com/choojs/nanorouter
[yo-yo]: https://github.com/maxogden/yo-yo
[unassertify]: https://github.com/unassert-js/unassertify
[window-performance]: https://developer.mozilla.org/en-US/docs/Web/API/Performance
buuh is choo. The design, the API, and most of the ideas are
[Yoshua Wuyts](https://github.com/yoshuawuyts)'s and the
[choojs contributors](https://github.com/choojs)'; the ESM groundwork
came from the [@pirxpilot](https://github.com/pirxpilot) fork line; this
fork modernized the engine and the toolchain around them. Per-file
attribution headers name the module and version each port came from.
MIT, as always.
+80
View File
@@ -0,0 +1,80 @@
// Real-browser benchmark (Playwright Chromium): @uhhm/buuh-html vs µhtml v5,
// same create/update scenarios as bench/render.js but with native DOM.
// nanohtml v1 is CJS-only and can't be import-mapped, so it only appears
// in the happy-dom bench.
//
// node bench/real-browser.js
import { chromium } from 'playwright'
import { startServer } from '../test/e2e/serve.js'
const srv = await startServer()
const browser = await chromium.launch()
const page = await browser.newPage()
const PAGE = `<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">${JSON.stringify({
imports: {
'@uhhm/buuh-html': '/packages/html/browser.js',
'@uhhm/buuh-html/morph': '/packages/html/morph.js',
uhtml: '/node_modules/uhtml/dist/prod/dom.js'
}
})}</script>
</head><body></body></html>`
await page.route('**/bench-page', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }))
await page.goto(srv.origin + '/bench-page')
const results = await page.evaluate(async () => {
const { default: html } = await import('@uhhm/buuh-html')
const { default: morph } = await import('@uhhm/buuh-html/morph')
const { html: uhtml, render: urender } = await import('uhtml')
const ROWS = 100
const data = (tick) => Array.from({ length: ROWS }, (_, i) => ({
id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS
}))
const bench = (fn) => {
let t = 0
const warmupEnd = performance.now() + 200
while (performance.now() < warmupEnd) fn(t++)
let ops = 0
const start = performance.now()
const end = start + 1000
while (performance.now() < end) { fn(t++); ops++ }
return Math.round((ops / (performance.now() - start)) * 1000)
}
const ourTable = (rows) => html`<table><tbody>${rows.map((r) => html`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
const uTable = (rows) => uhtml`<table><tbody>${rows.map((r) => uhtml`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
const out = {}
out['@uhhm/buuh-html create'] = bench((t) => ourTable(data(t)))
out['uhtml v5 create (fresh container)'] = bench((t) => {
const c = document.createElement('div')
urender(c, uTable(data(t)))
})
const live = ourTable(data(0))
document.body.appendChild(live)
out['@uhhm/buuh-html + nanomorph update'] = bench((t) => morph(live, ourTable(data(t))))
live.remove()
const c = document.createElement('div')
document.body.appendChild(c)
out['uhtml v5 update (in place)'] = bench((t) => urender(c, uTable(data(t))))
c.remove()
return out
})
console.log('\nreal Chromium — 100-row table, ops/s:')
for (const [name, ops] of Object.entries(results)) {
console.log(' ' + name.padEnd(38) + String(ops).padStart(8) + ' ops/s')
}
console.log()
await browser.close()
await srv.close()
+114
View File
@@ -0,0 +1,114 @@
// Rendering benchmarks: @uhhm/buuh-html vs nanohtml v1 vs µhtml.
//
// npm run bench
//
// Two environments:
// - server: pure string rendering in Node (no DOM at all)
// - browser-path: DOM construction in happy-dom. Caveat: happy-dom is a
// JS DOM, so absolute numbers are not real-browser numbers — but all
// contenders pay the same DOM tax, so the relative ordering is
// indicative of template-engine overhead.
//
// Scenarios: "create" builds a fresh 100-row table per iteration;
// "update" re-renders the same live tree with new data each iteration
// (choo-style engines build fresh + morph, µhtml updates holes in place).
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const ROWS = 100
const DURATION_MS = 500
const WARMUP_MS = 100
function data (tick) {
const rows = new Array(ROWS)
for (let i = 0; i < ROWS; i++) {
rows[i] = { id: i, label: 'row ' + i + ' rev ' + tick, selected: i === tick % ROWS }
}
return rows
}
function bench (name, fn) {
let t = performance.now()
const warmupEnd = t + WARMUP_MS
let tick = 0
while (performance.now() < warmupEnd) fn(tick++)
let ops = 0
const start = performance.now()
const end = start + DURATION_MS
while (performance.now() < end) {
fn(tick++)
ops++
}
const elapsed = performance.now() - start
const opsSec = Math.round((ops / elapsed) * 1000)
console.log(' ' + name.padEnd(34) + String(opsSec).padStart(8) + ' ops/s')
return opsSec
}
// ---------- server (string) rendering ----------
console.log('\nserver rendering — %d-row table to string:', ROWS)
{
const { default: ourHtml } = await import('@uhhm/buuh-html/server')
const nano = require('nanohtml') // main entry is the server renderer
const table = (html, rows) => html`<table><tbody>${rows.map((r) => html`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
bench('@uhhm/buuh-html (server)', (t) => String(table(ourHtml, data(t))))
bench('nanohtml v1 (server)', (t) => String(table(nano, data(t))))
}
// ---------- browser-path rendering (happy-dom) ----------
const { Window } = await import('happy-dom')
const win = new Window()
globalThis.window = win
globalThis.document = win.document
// µhtml touches DOM constructors as ambient globals at import time
for (const name of [
'DocumentFragment', 'HTMLElement', 'SVGElement', 'Element', 'Node',
'Text', 'Comment', 'Range', 'MutationObserver', 'customElements',
'requestAnimationFrame', 'cancelAnimationFrame'
]) {
if (!(name in globalThis) && name in win) globalThis[name] = win[name]
}
const { default: ourBrowserHtml } = await import('@uhhm/buuh-html/browser')
const { default: morph } = await import('@uhhm/buuh-html/morph')
const nanoBrowser = require('nanohtml/lib/browser.js')
const { html: uhtml, render: urender } = await import('uhtml')
const table = (html, rows) => html`<table><tbody>${rows.map((r) => html`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
console.log('\nbrowser-path create — fresh %d-row table per iteration (happy-dom):', ROWS)
bench('@uhhm/buuh-html (cached templates)', (t) => table(ourBrowserHtml, data(t)))
bench('nanohtml v1 (hyperx runtime)', (t) => table(nanoBrowser, data(t)))
bench('uhtml v5 (fresh container)', (t) => {
const c = document.createElement('div')
urender(c, uhtml`<table><tbody>${data(t).map((r) => uhtml`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`)
})
console.log('\nbrowser-path update — re-render same live tree (happy-dom):')
{
const live = table(ourBrowserHtml, data(0))
document.body.appendChild(live)
bench('@uhhm/buuh-html + nanomorph', (t) => morph(live, table(ourBrowserHtml, data(t))))
document.body.removeChild(live)
}
{
const live = table(nanoBrowser, data(0))
document.body.appendChild(live)
bench('nanohtml v1 + nanomorph', (t) => morph(live, table(nanoBrowser, data(t))))
document.body.removeChild(live)
}
{
const c = document.createElement('div')
document.body.appendChild(c)
const view = (t) => uhtml`<table><tbody>${data(t).map((r) => uhtml`<tr class=${r.selected ? 'selected' : ''}><td>${r.id}</td><td>${r.label}</td></tr>`)}</tbody></table>`
bench('uhtml v5 (keyed holes, in place)', (t) => urender(c, view(t)))
document.body.removeChild(c)
}
console.log('\ncaveat: happy-dom numbers are indicative (same DOM tax for all), not real-browser numbers.\n')
+83
View File
@@ -0,0 +1,83 @@
# Deploying a choo v8 + bankai v10 app
The server side of a v8 app is plain ESM — there is no server bundle.
Deploy is: ship the source + `dist/`, run a server.
## Plain Node (the blessed path)
```console
$ bankai build app.js --title "my app" --prerender /
$ bankai serve --h2 --port 443
```
`bankai serve` does static assets (immutable + precompressed), 103 Early
Hints, streaming SSR, and `window.initialState`. `--h2` matters:
browsers only act on Early Hints over HTTP/2 or HTTP/3. For a public
deployment use a real certificate (the built-in one is a generated
localhost cert) or front it with a TLS-terminating proxy.
## Docker
```dockerfile
FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npx bankai build app.js
EXPOSE 8080
CMD ["npx", "bankai", "serve"]
```
## Behind a proxy / CDN (and the HTTP/3 answer)
Node has no production HTTP/3 server yet (QUIC support in core is still
experimental and not exposed as an h3 listener), and that's fine: h3 is
infrastructure's job. Run `bankai serve` on h1 or h2 behind:
- **Caddy** — serves h2 + h3 by default, forwards to bankai:
```
example.com {
reverse_proxy localhost:8080
}
```
- **nginx ≥ 1.29** — `listen 443 quic;` for h3 plus
`early_hints on;`-era support for forwarding the 103.
- **Cloudflare / Fastly** — h3 at the edge automatically; both
understand and propagate Early Hints (Cloudflare has since 2021).
bankai's contract with these layers is just headers: it emits the 103
interim response and repeats the `Link` header on the final response, so
any hints-aware edge can act on them — including over h3/QUIC, where
Early Hints work exactly as on h2. What you should *not* expect is
HTTP/2-style server push: it's removed from Chrome and Firefox and was
never in HTTP/3. Hints + preload is the whole story now.
## Web-standard runtimes (Deno, Bun, edge)
`toStream()` returns a WHATWG `ReadableStream`, so the render half needs
no adapter at all:
```js
import createApp from './app.js'
import { documentHead, documentTail, resolveApp } from 'bankai'
export default {
async fetch (req) {
const app = createApp()
const state = {}
const url = new URL(req.url)
const body = app.toStream(url.pathname + url.search, state)
// stream the body through a TransformStream that wraps head + tail,
// or buffer if the route is small — your call, it's a web stream
return new Response(body, {
headers: { 'content-type': 'text/html; charset=utf-8' }
})
}
}
```
Serve `dist/` via the platform's static hosting and reuse
`dist/bankai.json` for the asset links. A first-class edge adapter
(head/tail wrapping + Early Hints via the platform APIs) is on the v8
roadmap; the primitives are deliberately already web-standard.
+65
View File
@@ -0,0 +1,65 @@
# Migrating a choo v7 app to v8
The API you know is intact: `choo()`, `app.use`, `app.route`,
`app.mount`, `app.toString`, `emit`, stores, `state.events`. What changed
is the plumbing: ESM everywhere, new package names, no compile step, and
a platform baseline of Node ≥ 24 + Baseline Widely Available browsers.
## The fast path
```console
$ npx @uhhm/buuh-migrate . # rewrite in place
$ npx @uhhm/buuh-migrate --dry . # or report only
```
The codemod converts simple top-level CJS to ESM, remaps specifiers, and
prints a note for everything it won't guess at. Then:
1. Add `"type": "module"` to package.json.
2. Swap dependencies for `@uhhm/buuh`, `@uhhm/buuh-html`, and friends.
3. Run your app. Read the console: hydration now *tells you* when server
and client markup disagree.
## Specifier map
| v7 | v8 |
|---|---|
| `choo` | `@uhhm/buuh` |
| `choo/html`, `nanohtml` | `@uhhm/buuh-html` |
| `nanohtml/raw` | `@uhhm/buuh-html/raw` |
| `nanomorph` | `@uhhm/buuh-html/morph` |
| `nanocomponent`, `choo/component` | `@uhhm/buuh-component` |
| `choo-devtools` | `@uhhm/buuh-devtools` |
| `choo-lazy-route` | `lazy()` from `@uhhm/buuh` |
| `nanobus`, `nanorouter`, `nanohref`, `nanotiming` | built into `@uhhm/buuh` |
| `nanoquery` | built in (`state.query`); use `URLSearchParams` elsewhere |
| `nanoraf`, `nanoassert`, `nanolru` | retired — platform/built-in |
## Behavior changes to know about
- **URLs decode once, and never crash.** A literal `%` in a path routed
v7 into a `URIError`; v8 keeps the raw segment. Params are no longer
double-decoded (`%2540``%40`, not `@`). Routes and locations are
NFC-normalized, so `café` matches however the é was composed.
`state.href` is decoded for reading.
- **Event handlers never serialize.** v7's server renderer emitted
`onclick=""`; v8 emits nothing — handlers are behavior, not markup.
- **Whitespace is preserved as authored** in browser renders (v7's
browser transform collapsed it). Server and browser output are now
byte-identical, which is what makes hydration adoption work.
- **`mount()` hydrates.** First render adopts server DOM in place and
warns on real mismatches; `<script>` tags and whitespace are ignored.
- **`toString()` got stricter, `toStream()` got capable.** Sync renders
behave exactly as v7. Async content — `state.prefetch` promises, lazy
routes, promise-valued template holes — requires `toStream()`, and
`toString()` says so instead of misrendering.
- **Views can't return arrays** (unchanged from v7) and document-level
roots (`html\`<body>…\``) work in the browser renderer too.
## Things that moved to bankai
sheetify → put a `style.css` next to your entry (client-only by
construction). `split-require` → native `import()` + `lazy()`.
`bankai start/build/serve/inspect` replace the v9 pipeline; HTTP/2 push
never shipped and is dead in browsers — bankai v10 sends 103 Early Hints
instead.
+101
View File
@@ -0,0 +1,101 @@
# Publishing buuh on project.uhhm.no
Two different distribution problems, two different mechanisms. Gitea
handles one of them natively; the other needs a static host.
## 1. `npm install` — Gitea Packages (yes, it does what you hope)
Gitea ships a real npm registry per user/org. For the `uhhm` org the
endpoint is:
```
https://project.uhhm.no/api/packages/uhhm/npm/
```
**Publishing** happens from the release workflow
(`.gitea/workflows/release.yml`): tag `v8.0.0`, push the tag, and every
workspace package is published with a `package:write` token stored as
the `PACKAGES_TOKEN` repo secret. Bump the workspace versions before
tagging.
**Consuming** needs one line of `.npmrc` in a project (or `~/.npmrc`):
```ini
@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/
```
Then plain npm works, and this is why the packages are scoped: npm
routes *by scope*, so `@uhhm/*` resolves against your Gitea while
everything else still comes from npmjs.org. No token is needed to
install if the packages are public (they inherit visibility from the
owner). Starting a project is:
```console
$ echo "@uhhm:registry=https://project.uhhm.no/api/packages/uhhm/npm/" >> .npmrc
$ npm i @uhhm/buuh @uhhm/buuh-html
$ npm i -D @uhhm/bankai
$ npx bankai start app.js
```
## 2. "CDN require" — the part Gitea does NOT do
This is the misunderstanding worth clearing up: **Gitea's npm registry
serves tarballs to package managers, not individual JavaScript files to
browsers.** There is no unpkg-style `https://…/@uhhm/buuh/index.js`
endpoint, and the generic-package download URLs serve
`application/octet-stream` — browsers refuse that for ES modules (strict
MIME checking). So an import map cannot point at Gitea directly.
What works instead, in order of effort:
**a. The single-file bundle + any static host (recommended).**
`npm run bundle` produces `dist-cdn/buuh.js` — the whole framework as
one minified ES module with named exports. The release workflow uploads
it to Gitea's generic package store as the archive of record; to make it
importable, serve a copy from any host that sends
`text/javascript` + CORS. Since you run uhhm.no, that's a few lines of
Caddy:
```
cdn.uhhm.no {
root * /srv/cdn
file_server
header Access-Control-Allow-Origin *
header /buuh@* Cache-Control "public, max-age=31536000, immutable"
}
```
Drop each release in as `buuh@8.0.0.js` (a `curl` from the generic
package URL, or an extra `scp` step in the workflow), and every project
on earth can do:
```html
<script type="importmap">
{ "imports": { "buuh": "https://cdn.uhhm.no/buuh@8.0.0.js" } }
</script>
<script type="module">
import { choo, html } from 'buuh'
</script>
```
That is the zero-build story with your own domain on it.
**b. Self-hosted esm.sh.** esm.sh is open source and can be pointed at
a custom npm registry — run it against the Gitea endpoint and you get
real CDN semantics (per-package URLs, versioning, bundling) for
everything you publish. More moving parts; worth it only if you want
per-package URLs rather than the one-file bundle.
**c. Raw Gitea file URLs — don't.** Gitea serves raw `.js` as
`text/plain` for safety, which module loading rejects. Fronting raw
URLs with a MIME-rewriting proxy works but is a hack with none of the
caching benefits of (a).
## Version hygiene
- Workspace versions are currently `8.0.0-dev`; set real versions
before the first tag (`npm version 8.0.0 --workspaces --no-git-tag-version`).
- The tag drives the generic-package version in the workflow, so keep
tags and package.json versions in step.
- `bankai` the bin name survives; the package is `@uhhm/bankai` so scope
routing works.
+84
View File
@@ -0,0 +1,84 @@
# RFC: choo v8 — same soul, modern engine
> Draft, to be posted as an issue on choojs/choo. Placeholders in
> [brackets]. Tone check before posting: continuation, not correction.
---
Hi everyone — long-time choo user, org member, still shipping things
with it. I'd like to propose (and have prototyped) a v8.
**The short version:** choo's design aged beautifully. The toolchain
around it didn't — browserify, the nanohtml transform, Babel 6, Travis.
v8 keeps the API and the philosophy and replaces every dead dependency
with either the platform or ~300 lines we own. I've built the whole
thing on a branch to make this a concrete conversation rather than a
wishlist: **[link to v8 branch]**.
## What stays exactly the same
The 7-line counter is still the 7-line counter. `choo()`, stores,
`emit`, tagged template views, morphing re-renders, `toString()` on the
server. `choo()` still works without `new`. A codemod
(`npx @choojs/migrate`) moves v7 apps across — validated against this
repo's own example app.
## What changes
- **ESM only, Node ≥ 24, Baseline browsers. Zero compile steps.** The
nanohtml browserify transform is replaced by a runtime template cache
(parse once per call site, clone per render — the µhtml technique,
adapted to our morph-based model). Consequence: **you can develop a
choo app with an import map and view-source**, no tooling at all.
- **Twelve packages become six.** nanobus/nanorouter/nanohref/nanotiming
fold into `@choojs/core` as attributed ports; nanoquery/nanoraf/
nanoassert retire to the platform. Smaller maintenance surface is the
point — single-maintainer fatigue is what stalled v7, and I don't want
to rebuild that failure mode.
- **SSR grows up: `toStream()`.** Web-standard `ReadableStream`,
progressive flushing through async template holes, `state.prefetch`
for store data, and hydration that *adopts* server DOM and warns on
real mismatches. Answers the async-route question too — #653 finally
gets both halves: `lazy(() => import('./view.js'))` in the browser,
awaited by `toStream` on the server.
- **bankai v10** — same one-command soul, rebuilt as a thin shell over
Vite 8/Rolldown. Only the client bundles (v8 server code runs
as-authored — no server build to rot). 103 Early Hints replace the
HTTP/2 push story, `style.css`/`sw.js` conventions replace sheetify
and the env-var service-worker dance, `--prerender` and `--h2`
included.
- **The size claim, honestly restated.** v7 said 4kb for choo alone
(templates compiled away by the transform). v8's *entire* framework —
core + html engine + morph + hydration — is 7.97 kB min+gzip
/ 7.15 kB brotli, enforced by CI. Apples-to-apples it's smaller;
the README now says the true number.
Numbers, tests, and the full decision log live on the branch:
109 unit tests (the v7 suite among them, behavior preserved) + 7
Playwright tests in real Chromium covering hydration, streaming, and
bankai dev/prod. Also fixed along the way: the non-ASCII/`%` URL
crashes (routing now parses with WHATWG URL, decodes once, and
NFC-normalizes).
## What I'm asking
1. **@yoshuawuyts** — a blessing costs one emoji and would mean a lot.
Zero obligation beyond that; the credits already say what this
builds on.
2. **@pirxpilot** — your ESM fork line is the only living continuation
of this code and v8's core started from that groundwork. I'd love to
co-maintain rather than fork-in-parallel; either way, thank you.
3. **npm publish rights** for `choo`, `bankai`, and the nano*
packages, or a nod to ship under `@choojs/*` with the old names as
deprecation pointers after 8.0.0 exists (nothing gets deprecated
before then; master and v7 stay untouched).
4. **API feedback**, especially: `lazy()` wrapper vs thenable route
handlers; the `state.prefetch` contract; anything in the migration
doc that reads as a betrayal rather than an upgrade.
**Comment window: three weeks from posting.** After that I'll take
silence as consent, keep working on the v8 branch in the open, and cut
pre-releases under a `next` tag. If this lands wrong for anyone, say so
— the plan bends.
🚂🚋🚋🚋🚋🚋
+152
View File
@@ -0,0 +1,152 @@
# buuh (choo v8) — branch notes
> Naming note: this work now ships publicly as **buuh**, a friendly fork
> under the `uhhm` org on project.uhhm.no, with packages scoped
> `@uhhm/*` (scoped so npm's per-scope registry routing works — and so
> we never squat upstream's names on any registry). The docs below use
> both names; "v8" refers to this modernization effort either way. The
> upstream RFC stays drafted in `docs/upstream-rfc-draft.md` if this
> ever goes home.
This branch is the working tree for the v8 modernization effort. The v7 code
at the repo root is untouched and stays authoritative until 8.0.0 ships;
everything new lives under `packages/`.
## Layout
- `packages/core``@uhhm/buuh`: the Choo class, same API as choo v7
(`use`/`route`/`start`/`mount`/`toString`/`emit`), ported to ESM. The
nano* internals are consolidated into `lib/` as attributed ports:
- `lib/bus.js` ← nanobus 4.5.0
- `lib/router.js` ← nanorouter 4.0.0 + wayfarer 7.0.1 (+ trie)
- `lib/cache.js` ← choo component/cache.js + nanolru 1.0.0
- `lib/raf.js` ← nanoraf 3.1.0, `lib/href.js` ← nanohref 3.1.0
- `lib/timing.js` ← nanotiming 7.3.1 (unified on global `performance`)
- `lib/dom.js` ← document-ready 2.0.1 + scroll-to-anchor 1.0.0
- `lib/query.js` — nanoquery replaced by `URLSearchParams`
- `packages/html``@uhhm/buuh-html`: the rendering package.
- `server.js` — server-side tagged template (← nanohtml 1.10.0 server,
transform branches removed; pure runtime)
- `browser.js` — runtime-only cached template tag: each template literal
is parsed once (WeakMap keyed on its strings array) into a <template>
plus hole instructions; renders clone and fill. Values never pass
through innerHTML. Document-level roots (<body> etc.) parse via
DOMParser since <template> drops them. Known limits: no dynamic tag
names, no holes in raw-text elements, SVG fragments need their <svg>
root, and (unlike v7) whitespace is preserved as authored — which is
what makes server and browser output byte-identical.
- `morph.js` — ← nanomorph 5.4.3, consolidated to one module
- `raw.js` — mark pre-encoded strings (works with both renderers)
- `packages/component``@uhhm/buuh-component`: ← nanocomponent 6.6.0 +
on-load 3.4.1 as ES classes; the future island/hydration boundary.
- `packages/devtools``@uhhm/buuh-devtools`: window.choo with live state,
event log, timings via PerformanceObserver, copy(); no-op on the server.
- `packages/migrate``@uhhm/buuh-migrate`: the `choo-migrate` codemod.
Regex-based on purpose: converts simple top-level CJS to ESM, remaps
specifiers (choo → @uhhm/buuh, nanohtml → @uhhm/buuh-html, …), points
retired packages at their replacements, and reports everything it
refuses to guess at.
- `examples/counter` — the isomorphic proof: one app module, mounted
zero-build in the browser via import map (`index.html`), string-rendered
in Node (`render.js`).
- `examples/streaming` — streaming SSR demo server
(`node examples/streaming/server.js`).
## Status vs the modernization plan
- [x] Phase 1: monorepo scaffold, ESM ports, choo v7 node test suite green
on `node --test` (Node ≥ 24), CI on GitHub Actions
- [x] URL normalization fix: WHATWG URL parsing, single decode with raw
fallback (no more URIError on '%'), NFC matching, per-segment
wildcard decode, decoded state.href
- [x] Phase 2 (core): browser renderer rewrite, `@uhhm/buuh-component`,
zero-build counter example, full-app integration test in happy-dom
- [x] Phase 2 (tail): adoption-style hydration with mismatch warnings
(`@uhhm/buuh-html/hydrate`, wired into `mount()`), real-browser
Playwright e2e (zero-build page, SSR-then-hydrate page, adoption
proof; CI job included), benchmarks vs nanohtml v1 / µhtml
## Benchmarks (2026-09, 100-row table, `npm run bench` / `bench:browser`)
Real Chromium: @uhhm/buuh-html creates fresh trees ~12% faster than µhtml v5
(3.5k vs 3.1k ops/s) — the parse-once/clone design pays off in native DOM.
µhtml updates in place ~5x faster than our fresh-tree + nanomorph loop
(3.0k vs 0.6k ops/s): that is choo's architectural cost, mitigated in real
apps by @uhhm/buuh-component caching (proxy nodes skip unchanged subtrees),
and the number to beat if Phase 3 explores keyed-hole optimizations.
Server string rendering is on par with nanohtml v1 (~13k ops/s, within
6%). happy-dom numbers in bench/render.js are indicative only.
- [x] Phase 3: v8 wiring, all shipped together:
- `toStream(location, state)` — web-standard ReadableStream of
UTF-8; `new Response(stream)` on web servers,
`Readable.fromWeb(stream).pipe(res)` on Node. Progressive: the
server tag now builds a parts list, so promises/async iterables
in child position flush the shell first and stream the rest in
document order (proven byte-level and in real Chromium).
- `state.prefetch` — stores push promises during init; toStream
awaits them before rendering; toString refuses them loudly.
- `lazy(loader, loadingView?)` — the answer to choojs/choo#653,
both halves at once: browser renders the loading view (or holds
the current tree) until the dynamic import lands, server awaits
it in toStream; toString fails with guidance. Views cache after
first load.
- `@uhhm/buuh-devtools` and the `choo-migrate` codemod (validated by
migrating choo's own v7 example and running the result on v8).
- [ ] Phase 3 follow-ups for the RFC: API shape feedback on lazy()
(thenable handlers vs wrapper), serializing streamed state for
hydration of async holes (bankai v10 territory).
- [x] Phase 4: bankai v10 — `packages/bankai`, a thin shell over Vite 8
(Rolldown). The server side needs no build at all: v8 apps are
plain ESM that Node runs as-authored, so only the client bundles.
- `bankai start <entry>` — dev: Vite middleware mode + HMR, with
per-request streaming SSR through vite.ssrLoadModule and a
virtual client entry (the user writes one isomorphic module;
bankai generates the two lines of browser glue).
- `bankai build <entry>` — client bundle + Vite manifest +
dist/bankai.json route/asset metadata + service worker (if
`<entry dir>/sw.js` exists, built with the precache list
injected) + brotli/gzip precompression of every text asset.
- `bankai serve` — production: static assets with immutable
caching and precompressed-variant negotiation, 103 Early Hints
(`res.writeEarlyHints`) carrying the route's assets before every
SSR page, streaming render, and a `window.initialState` tail
(script-safe serialization, choo internals filtered; hydration
ignores server-only scripts).
- `bankai inspect` — raw/gzip/brotli size table from the manifest.
All proven end-to-end in real Chromium: both dev and prod pages
hydrate with zero console errors and zero mismatch warnings, and
the 103 interim response is asserted at the HTTP level.
- [x] Phase 4 follow-ups: `bankai serve --h2` (local certs via openssl,
103 verified with a real h2 client), `bankai build --prerender`,
SSR `<title>` from state (the server reads the first stream chunk
before writing the head), the `style.css` convention (client-only
CSS — the sheetify answer), and the CI wire-size budget
(`npm run size`: 7.97 kB min+gzip for the whole framework,
budget 8.5 kB).
- [x] Phase 5: README rewritten for v8 (honest size claim, credits),
`docs/migrating-v7-to-v8.md`, `docs/deploy.md` (Node, Docker,
proxy/CDN with the HTTP/3 story — h3 is infrastructure's job,
bankai's contract is the 103 + Link headers any hints-aware edge
propagates — and web-standard runtimes), and `docs/rfc.md`: the
draft announcement to post on choojs/choo, with the pings, the
asks, and the three-week comment window.
- [ ] Post-RFC: publish pre-releases under a next tag, refresh
choo.io/handbook (separate repos), deprecation pointers on retired
packages only after 8.0.0, first-class edge adapter for bankai.
- [ ] Phase 4: bankai v10 (Vite 8/Rolldown shell, SSR middleware,
103 Early Hints, service worker, precompression)
- [ ] Phase 5: docs, examples, launch
## Deliberate changes from v7
- `choo()` still works without `new`; `Choo` is also a named export.
- nanoquery → `URLSearchParams` (same output shape, repeated keys → arrays).
- Assertions live in `lib/assert.js` so production builds can strip them.
- Everything targets Node ≥ 24 and Baseline Widely Available browsers; there
is no compile step and no transpilation anywhere.
## Attribution
All ported modules are MIT-licensed work by the original choojs/nano*
authors and contributors; per-file headers note the source package and
version. This branch exists to carry that work forward, not to replace it.
+40
View File
@@ -0,0 +1,40 @@
// The classic choo counter. This one module is the whole app, and both
// sides consume it: the browser mounts it (import map resolves
// @uhhm/buuh-html to the DOM renderer), Node stringifies it (same specifier
// resolves to the string renderer). That's the isomorphic contract.
import choo from '@uhhm/buuh'
import html from '@uhhm/buuh-html'
export default function createApp () {
const app = choo()
app.use(countStore)
app.route('/', mainView)
// the demo gets served from arbitrary subpaths (npx serve ., test
// servers); a wildcard fallback makes it mount anywhere
app.route('*', mainView)
return app
}
function mainView (state, emit) {
emit(state.events.DOMTITLECHANGE, `count is ${state.count}`)
return html`
<body>
<h1>count is ${state.count}</h1>
<button onclick=${onclick}>Increment</button>
</body>
`
function onclick () {
emit('increment', 1)
}
}
function countStore (state, emitter) {
state.count = state.count || 0
emitter.on('increment', function (count) {
state.count += count
emitter.emit('render')
})
}
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>choo v8 counter — zero build</title>
<!--
No bundler, no compiler, no node_modules in the browser: an import map
and native ES modules. Serve this directory from the repo root, e.g.
npx serve .
then open /examples/counter/
-->
<script type="importmap">
{
"imports": {
"@uhhm/buuh": "../../packages/core/index.js",
"@uhhm/buuh/timing": "../../packages/core/lib/timing.js",
"@uhhm/buuh-html": "../../packages/html/browser.js",
"@uhhm/buuh-html/raw": "../../packages/html/raw.js",
"@uhhm/buuh-html/morph": "../../packages/html/morph.js",
"@uhhm/buuh-html/hydrate": "../../packages/html/hydrate.js"
}
}
</script>
</head>
<body>
<script type="module">
import createApp from './app.js'
createApp().mount('body')
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
// Server-side render of the exact same app module the browser mounts:
// node examples/counter/render.js
import createApp from './app.js'
process.stdout.write(createApp().toString('/') + '\n')
+29
View File
@@ -0,0 +1,29 @@
/* The style.css convention: bankai's client entry imports this file
automatically (CSS is a client concern — server code never sees it),
Vite extracts and hashes it, and the server preloads it via Early
Hints. The v8 answer to sheetify. */
body {
font-family: system-ui, sans-serif;
max-width: 36rem;
margin: 4rem auto;
padding: 0 1rem;
line-height: 1.5;
}
h1 {
font-size: 1.6rem;
}
button {
font: inherit;
padding: 0.4rem 1.2rem;
border: 2px solid currentColor;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
button:hover {
background: #ffc0cb55;
}
+28
View File
@@ -0,0 +1,28 @@
/* global __BANKAI_ASSETS__ */
// Service worker built by `bankai build`: the precache list is injected
// at build time from the asset manifest (the choo-service-worker
// convention, manifest edition). Register it from your app with
// navigator.serviceWorker.register('/sw.js').
const CACHE = 'counter-v1'
const ASSETS = __BANKAI_ASSETS__
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
)
})
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
)
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((hit) => hit || fetch(event.request))
)
})
+35
View File
@@ -0,0 +1,35 @@
// Streaming SSR demo: the shell flushes immediately, the slow section
// streams in when its promise resolves. Run it:
// node examples/streaming/server.js
//
// This page is server-rendered only (no hydration script): async holes
// stream on the server, while client-side views must be synchronous.
// Serializing streamed state for hydration is bankai v10 territory.
import choo from '@uhhm/buuh'
import html from '@uhhm/buuh-html'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
export default function createApp ({ delay = 500 } = {}) {
const app = choo()
app.use((state) => { state.delay = delay })
app.route('/', mainView)
app.route('*', mainView)
return app
}
function mainView (state, emit) {
return html`
<body>
<h1>choo streams</h1>
<p>This shell was flushed before the slow part finished.</p>
${slowSection(state)}
</body>
`
}
async function slowSection (state) {
await wait(state.delay)
return html`<section id="slow">…and this arrived ${state.delay}ms later, same response.</section>`
}
+27
View File
@@ -0,0 +1,27 @@
// Minimal streaming SSR server on plain Node http.
// node examples/streaming/server.js
// The same ReadableStream plugs into web-standard servers as
// `new Response(app.toStream('/'))` — Readable.fromWeb is just the
// Node http bridge.
import { createServer } from 'node:http'
import { Readable } from 'node:stream'
import createApp from './app.js'
const PORT = process.env.PORT || 8080
createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.write('<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>choo streams</title></head>\n')
const body = Readable.fromWeb(createApp().toStream('/'))
body.pipe(res, { end: false })
body.on('end', () => res.end('\n</html>'))
body.on('error', (err) => {
console.error(err)
res.destroy()
})
}).listen(PORT, () => {
console.log(`streaming on http://localhost:${PORT} — watch the slow section arrive`)
})
+20 -69
View File
@@ -1,78 +1,29 @@
{
"name": "choo",
"version": "7.1.0",
"description": "A 4kb framework for creating sturdy frontend applications",
"main": "index.js",
"files": [
"index.js",
"index.d.ts",
"html/index.js",
"html/raw.js",
"html/index.d.ts",
"component/cache.js",
"component/index.js",
"dist",
"example"
"name": "@uhhm/buuh-monorepo",
"private": true,
"version": "8.0.0",
"description": "Monorepo for buuh \u2014 a friendly fork of choo, rebuilt for the modern platform",
"type": "module",
"workspaces": [
"packages/*"
],
"browser": {
"assert": "nanoassert"
"engines": {
"node": ">=24"
},
"scripts": {
"build": "mkdir -p dist/ && browserify index -s Choo -p bundle-collapser/plugin > dist/bundle.js && browserify index -s Choo -p tinyify > dist/bundle.min.js && cat dist/bundle.min.js | gzip --best --stdout | wc -c | pretty-bytes",
"deps": "dependency-check --entry ./html/index.js . && dependency-check . --extra --no-dev --entry ./html/index.js --entry ./component/index.js -i nanoassert",
"inspect": "browserify --full-paths index -p tinyify | discify --open",
"prepublishOnly": "npm run build",
"start": "bankai start example",
"test": "standard && npm run deps && npm run test:types && npm run test:node && npm run test:browser",
"test:types": "tsd",
"test:node": "node test/node.js | tap-format-spec",
"test:browser": "browserify test/browser.js | tape-run | tap-format-spec"
"test": "node --test packages/*/test/*.test.js",
"test:e2e": "node --test test/e2e/*.test.js",
"bench": "node bench/render.js",
"bench:browser": "node bench/real-browser.js",
"size": "node scripts/size.js",
"bundle": "node scripts/bundle.js"
},
"repository": "choojs/choo",
"keywords": [
"client",
"frontend",
"framework",
"minimal",
"composable",
"tiny"
],
"repository": "https://project.uhhm.no/uhhm/buuh",
"license": "MIT",
"dependencies": {
"document-ready": "^2.0.1",
"nanoassert": "^1.1.0",
"nanobus": "^4.4.0",
"nanocomponent": "^6.5.0",
"nanohref": "^3.0.0",
"nanohtml": "^1.1.0",
"nanolru": "^1.0.0",
"nanomorph": "^5.1.2",
"nanoquery": "^1.1.0",
"nanoraf": "^3.0.0",
"nanorouter": "^4.0.0",
"nanotiming": "^7.0.0",
"scroll-to-anchor": "^1.0.0"
},
"devDependencies": {
"@tap-format/spec": "^0.2.0",
"browserify": "^16.2.2",
"bundle-collapser": "^1.2.1",
"dependency-check": "^3.1.0",
"disc": "^1.3.3",
"hyperscript": "^2.0.2",
"pretty-bytes-cli": "^2.0.0",
"spok": "^0.9.1",
"standard": "^11.0.1",
"tape": "^4.6.3",
"tape-run": "^6.0.0",
"tinyify": "^2.2.0",
"tsd": "^0.11.0"
},
"tsd": {
"compilerOptions": {
"lib": [
"DOM"
]
}
"happy-dom": "^20.14.0",
"nanohtml": "^1.10.0",
"playwright": "^1.63.0",
"uhtml": "^5.0.9"
}
}
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// bankai <command> [entry] [options]
//
// bankai start <entry> dev server: Vite + HMR + streaming SSR
// bankai build <entry> production build (client bundle, manifest,
// service worker, precompression)
// bankai serve production server for a built app: static
// assets + 103 Early Hints + streaming SSR
// bankai inspect size report for the built output
//
// options: --port <n> --out <dir> --title <text>
// --prerender </,/about> (build: comma-separated routes)
// --h2 (serve: HTTP/2 + local cert, so
// browsers act on the Early Hints)
import { parseArgs } from 'node:util'
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
port: { type: 'string' },
out: { type: 'string', default: 'dist' },
title: { type: 'string', default: 'choo' },
prerender: { type: 'string' },
h2: { type: 'boolean', default: false }
}
})
const [command, entry] = positionals
const port = values.port ? Number(values.port) : 8080
try {
if (command === 'start') {
requireEntry(entry)
const { default: dev } = await import('./lib/dev.js')
const { origin } = await dev({ entry, port, title: values.title })
console.log(`bankai: dev server streaming on ${origin}`)
} else if (command === 'build') {
requireEntry(entry)
const { default: build } = await import('./lib/build.js')
const prerender = values.prerender ? values.prerender.split(',').map((r) => r.trim()).filter(Boolean) : []
const { outDir, compressed, prerendered } = await build({ entry, outDir: values.out, title: values.title, prerender })
const extra = prerendered.length ? `, ${prerendered.length} route(s) prerendered` : ''
console.log(`bankai: built to ${outDir} (${compressed} asset(s) precompressed${extra}) — bankai serve to run it`)
} else if (command === 'serve') {
const { default: serve } = await import('./lib/serve.js')
const { origin } = await serve({ outDir: values.out, port, h2: values.h2 })
console.log(`bankai: production server on ${origin}`)
} else if (command === 'inspect') {
const { default: inspect } = await import('./lib/inspect.js')
await inspect({ outDir: values.out })
} else {
console.log('usage: bankai <start|build|serve|inspect> [entry] [--port n] [--out dir] [--title text]')
process.exit(command ? 1 : 0)
}
} catch (err) {
console.error('bankai:', err.message)
process.exit(1)
}
function requireEntry (value) {
if (!value) {
console.error('bankai: an entry module is required, e.g. bankai start app.js')
process.exit(1)
}
}
+6
View File
@@ -0,0 +1,6 @@
export { default as dev } from './lib/dev.js'
export { default as build } from './lib/build.js'
export { default as serve } from './lib/serve.js'
export { default as inspect } from './lib/inspect.js'
export { documentHead, documentTail, serializeState, assetLinks } from './lib/document.js'
export { resolveApp } from './lib/app.js'
+23
View File
@@ -0,0 +1,23 @@
// The isomorphic entry contract (plan decision D5): the entry module's
// default export is either a choo app instance (typically
// `export default app.mount('body')` — mount() on the server records the
// selector and returns the app) or a factory returning one (preferred:
// fresh state per request). One module, three consumers: browser mount,
// server render, build metadata.
export function resolveApp (mod, entry) {
const value = mod && mod.default
if (!value) {
throw new Error(`bankai: ${entry} has no default export — export your choo app (or a function returning one)`)
}
// a factory is a plain function; an app instance has .mount
const app = typeof value === 'function' && !value.mount ? value() : value
if (typeof app.toStream !== 'function') {
throw new Error(`bankai: the default export of ${entry} is not a choo v8 app (missing toStream)`)
}
return app
}
export function selectorOf (app) {
return app.selector || 'body'
}
+147
View File
@@ -0,0 +1,147 @@
// bankai build — the production build. Only the CLIENT is bundled: the
// server side of a choo v8 app is plain ESM that Node runs as-authored,
// so there is no server build to rot. Outputs:
// dist/assets/* hashed client chunks + css
// dist/.vite/manifest.json Vite's build manifest
// dist/bankai.json bankai's route/asset metadata for serving
// dist/**/index.html prerendered routes (--prerender)
// dist/sw.js service worker (if <entry dir>/sw.js exists),
// with the precached asset list defined in
// *.br / *.gz precompressed siblings for every text asset
import { readFile, writeFile, readdir, stat, access, mkdir } from 'node:fs/promises'
import { join, dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { brotliCompress, gzip, constants } from 'node:zlib'
import { promisify } from 'node:util'
import bankaiPlugin, { findCss, CLIENT_ID } from './plugin.js'
import { resolveApp } from './app.js'
import { documentHead, documentTail } from './document.js'
const brotli = promisify(brotliCompress)
const gzipP = promisify(gzip)
export default async function buildApp ({ entry, outDir = 'dist', title = 'choo', prerender = [] }) {
const { build } = await import('vite')
entry = resolve(entry)
outDir = resolve(outDir)
const css = await findCss(entry)
await build({
appType: 'custom',
plugins: [bankaiPlugin(entry, { css })],
logLevel: 'warn',
// real-world stylesheets (tachyons et al) carry IE-era hacks like
// *zoom; strip them instead of refusing to build
css: { lightningcss: { errorRecovery: true } },
build: {
outDir,
emptyOutDir: true,
manifest: true,
rollupOptions: { input: { main: CLIENT_ID } }
}
})
// derive the route-independent asset set from Vite's manifest
const manifest = JSON.parse(await readFile(join(outDir, '.vite', 'manifest.json'), 'utf8'))
const entryChunk = Object.values(manifest).find((m) => m.isEntry)
const modulepreload = (entryChunk.imports || []).map((key) => '/' + manifest[key].file)
const assets = {
scripts: ['/' + entryChunk.file],
modulepreload,
css: collectCss(manifest, entryChunk)
}
const meta = { title, entry, assets, builtAt: new Date().toISOString() }
await writeFile(join(outDir, 'bankai.json'), JSON.stringify(meta, null, 2))
// static prerender: routes rendered through the same toStream path the
// server uses, written as <route>/index.html
const prerendered = []
if (prerender.length) {
const mod = await import(pathToFileURL(entry))
for (const route of prerender) {
const app = resolveApp(mod, entry)
const state = {}
let body = ''
const decoder = new TextDecoder()
for await (const chunk of app.toStream(route, state)) {
body += decoder.decode(chunk, { stream: true })
}
const page = documentHead({ title: state.title || title, ...assets }) + body + documentTail(state)
const rel = route === '/' ? 'index.html' : join(route.replace(/^\//, '').replace(/\/$/, ''), 'index.html')
const target = join(outDir, rel)
await mkdir(dirname(target), { recursive: true })
await writeFile(target, page)
prerendered.push(route)
}
}
// service worker: <entry dir>/sw.js, built standalone with the asset
// list injected — the choo-service-worker convention, manifest edition
const swSource = join(dirname(entry), 'sw.js')
if (await exists(swSource)) {
const precache = [
...(prerendered.length ? prerendered : ['/']),
...assets.scripts, ...assets.modulepreload, ...assets.css
]
await build({
appType: 'custom',
logLevel: 'warn',
define: { __BANKAI_ASSETS__: JSON.stringify(precache) },
build: {
outDir,
emptyOutDir: false,
rollupOptions: {
input: { sw: swSource },
output: { entryFileNames: 'sw.js' }
}
}
})
}
// precompress text assets so the server never compresses at request time
const compressed = await precompress(outDir)
return { outDir, meta, compressed, prerendered }
}
function collectCss (manifest, entryChunk) {
const css = new Set(entryChunk.css || [])
for (const key of entryChunk.imports || []) {
for (const file of manifest[key].css || []) css.add(file)
}
return [...css].map((file) => '/' + file)
}
const COMPRESSIBLE = /\.(js|mjs|css|html|json|svg|txt|map)$/
async function precompress (dir) {
let count = 0
for (const name of await readdir(dir)) {
const path = join(dir, name)
const info = await stat(path)
if (info.isDirectory()) {
count += await precompress(path)
continue
}
if (!COMPRESSIBLE.test(name) || info.size < 1024) continue
const data = await readFile(path)
await writeFile(path + '.br', await brotli(data, {
params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY }
}))
await writeFile(path + '.gz', await gzipP(data, { level: constants.Z_BEST_COMPRESSION }))
count++
}
return count
}
async function exists (path) {
try {
await access(path)
return true
} catch (e) {
return false
}
}
+69
View File
@@ -0,0 +1,69 @@
// bankai start — the dev server: Vite middleware mode for the module
// graph and HMR, per-request streaming SSR of the same entry through
// vite.ssrLoadModule so server code shares Vite's transforms and cache.
import { createServer } from 'node:http'
import bankaiPlugin, { findCss, CLIENT_URL } from './plugin.js'
import { resolveApp } from './app.js'
import { documentHead, documentTail } from './document.js'
export default async function dev ({ entry, port = 8080, title = 'choo' }) {
const { createServer: createViteServer } = await import('vite')
const css = await findCss(entry) // style.css convention; restart to add it
const vite = await createViteServer({
appType: 'custom',
server: { middlewareMode: true },
plugins: [bankaiPlugin(entry, { css })],
css: { lightningcss: { errorRecovery: true } },
logLevel: 'warn'
})
const server = createServer((req, res) => {
vite.middlewares(req, res, async () => {
// not an asset Vite knows: this is a page navigation — SSR it
try {
const mod = await vite.ssrLoadModule(entry)
const app = resolveApp(mod, entry)
const state = {}
// Read the first chunk before writing the head: by then stores
// have run and the view has rendered up to its first async hole,
// so DOMTitleChange emits have landed in state.title.
const reader = app.toStream(req.url, state).getReader()
const first = await reader.read()
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.write(documentHead({
title: state.title || title,
scripts: ['/@vite/client', CLIENT_URL]
}))
if (!first.done) res.write(first.value)
for (;;) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
}
res.end(documentTail(state))
} catch (err) {
vite.ssrFixStacktrace(err)
console.error(err)
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' })
res.end('bankai dev error:\n\n' + (err.stack || err.message))
}
})
})
await new Promise((resolve) => server.listen(port, resolve))
return {
server,
vite,
port: server.address().port,
origin: `http://localhost:${server.address().port}`,
close: async () => {
await vite.close()
await new Promise((resolve) => server.close(resolve))
}
}
}
+55
View File
@@ -0,0 +1,55 @@
// The HTML document around the app's streamed body. bankai owns the
// <head> (charset, viewport, title, asset links) and the tail
// (window.initialState + </html>); the app's view owns <body>.
// Page scripts live in <head> (type=module defers itself); the one
// exception is the initialState script, which must be inline and is
// emitted after the body — inline scripts execute during parse, module
// scripts only after it, so the order still holds.
export function documentHead ({ title, css = [], modulepreload = [], scripts = [] }) {
let head = '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n'
if (title) head += `<title>${escapeHtml(title)}</title>\n`
for (const href of css) {
head += `<link rel="stylesheet" href="${escapeHtml(href)}">\n`
}
for (const href of modulepreload) {
head += `<link rel="modulepreload" href="${escapeHtml(href)}">\n`
}
for (const src of scripts) {
head += `<script type="module" src="${escapeHtml(src)}"></script>\n`
}
head += '</head>\n'
return head
}
export function documentTail (state) {
return `\n<script>window.initialState=${serializeState(state)}</script>\n</html>\n`
}
// choo internals that every boot recomputes \u2014 no point shipping them
const RECOMPUTED = new Set(['events', 'cache', 'prefetch'])
// JSON that is safe to embed in an inline <script>: no '</script>' or
// '<!--' breakouts, no raw line separators
export function serializeState (state) {
return JSON.stringify(state, function (key, value) {
if (this === state && RECOMPUTED.has(key)) return undefined
return value
})
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}
// Early Hints / Link header values for a route's assets
export function assetLinks ({ css = [], modulepreload = [], scripts = [] }) {
const links = []
for (const href of scripts) links.push(`<${href}>; rel=modulepreload`)
for (const href of modulepreload) links.push(`<${href}>; rel=modulepreload`)
for (const href of css) links.push(`<${href}>; rel=preload; as=style`)
return links
}
function escapeHtml (str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
+56
View File
@@ -0,0 +1,56 @@
// bankai inspect — what did the build produce, and how big is it on the
// wire? Reads the manifest + dist and prints raw/gzip/brotli sizes.
import { readFile, readdir, stat } from 'node:fs/promises'
import { join, resolve, relative } from 'node:path'
export default async function inspect ({ outDir = 'dist' }) {
outDir = resolve(outDir)
const meta = JSON.parse(await readFile(join(outDir, 'bankai.json'), 'utf8'))
const rows = []
await walk(outDir, rows, outDir)
rows.sort((a, b) => b.raw - a.raw)
console.log(`\nbankai build — ${meta.title} (${meta.builtAt})\n`)
console.log(' ' + 'file'.padEnd(44) + 'raw'.padStart(10) + 'gzip'.padStart(10) + 'brotli'.padStart(10))
let totals = { raw: 0, gz: 0, br: 0 }
for (const row of rows) {
totals.raw += row.raw
totals.gz += row.gz || 0
totals.br += row.br || 0
console.log(' ' + row.file.padEnd(44) + kb(row.raw) + kb(row.gz) + kb(row.br))
}
console.log(' ' + 'total'.padEnd(44) + kb(totals.raw) + kb(totals.gz) + kb(totals.br) + '\n')
return rows
}
async function walk (dir, rows, root) {
for (const name of await readdir(dir)) {
if (name === '.vite') continue
const path = join(dir, name)
const info = await stat(path)
if (info.isDirectory()) {
await walk(path, rows, root)
continue
}
if (name.endsWith('.br') || name.endsWith('.gz')) continue
const row = { file: relative(root, path), raw: info.size, gz: null, br: null }
row.gz = await sizeOf(path + '.gz')
row.br = await sizeOf(path + '.br')
rows.push(row)
}
}
async function sizeOf (path) {
try {
return (await stat(path)).size
} catch (e) {
return null
}
}
function kb (bytes) {
if (bytes === null) return '—'.padStart(10)
return (bytes < 1024 ? bytes + ' B' : (bytes / 1024).toFixed(1) + ' kB').padStart(10)
}
+45
View File
@@ -0,0 +1,45 @@
// Vite plugin: a virtual client entry that mounts the user's app. The
// user writes one isomorphic module; bankai generates the two lines of
// browser glue instead of asking for a second file.
export const CLIENT_ID = 'bankai:client'
const RESOLVED_CLIENT_ID = '\0' + CLIENT_ID
// the URL a browser uses to import the virtual module through Vite's dev
// middleware ('\0' encodes as '__x00__')
export const CLIENT_URL = '/@id/__x00__' + CLIENT_ID
export default function bankaiPlugin (entry, { css } = {}) {
return {
name: 'bankai',
resolveId (id) {
if (id === CLIENT_ID) return RESOLVED_CLIENT_ID
},
load (id) {
if (id === RESOLVED_CLIENT_ID) {
return [
// the style.css convention: CSS is a client concern, so the
// client glue imports it and server code never sees it — the
// v8 answer to what sheetify transforms did in v9
...(css ? [`import ${JSON.stringify(css)}`] : []),
`import create from ${JSON.stringify(entry)}`,
'const app = typeof create === "function" && !create.mount ? create() : create',
'app.mount(app.selector || "body")'
].join('\n')
}
}
}
}
// <entry dir>/style.css, when it exists
export async function findCss (entry) {
const { access } = await import('node:fs/promises')
const { join, dirname } = await import('node:path')
const candidate = join(dirname(entry), 'style.css')
try {
await access(candidate)
return candidate
} catch (e) {
return null
}
}
+176
View File
@@ -0,0 +1,176 @@
// bankai serve — the production server. Static assets from dist
// (precompressed variants negotiated, hashed assets cached forever);
// prerendered routes served as static HTML; everything else is a page:
// 103 Early Hints from the build manifest, then streaming SSR of the
// entry module — imported natively, because the server side never
// needed a build. `h2: true` serves HTTP/2 with a generated local cert
// (allowHTTP1 for tools) — browsers only act on Early Hints over h2/h3,
// so production deployments want this or a fronting h2 proxy.
import http from 'node:http'
import http2 from 'node:http2'
import { execFile } from 'node:child_process'
import { createReadStream } from 'node:fs'
import { readFile, stat, mkdir } from 'node:fs/promises'
import { join, normalize, extname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { resolveApp } from './app.js'
import { documentHead, documentTail, assetLinks } from './document.js'
const execFileP = promisify(execFile)
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.map': 'application/json',
'.txt': 'text/plain; charset=utf-8',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.ico': 'image/x-icon'
}
export default async function serve ({ outDir = 'dist', port = 8080, h2 = false }) {
outDir = resolve(outDir)
const meta = JSON.parse(await readFile(join(outDir, 'bankai.json'), 'utf8'))
const mod = await import(pathToFileURL(meta.entry))
const links = assetLinks(meta.assets)
async function handler (req, res) {
const url = new URL(req.url, 'http://localhost')
const pathname = decodeURIComponent(url.pathname)
// static asset?
const file = normalize(join(outDir, pathname))
if (file.startsWith(outDir) && extname(pathname)) {
if (await isFile(file)) return sendFile(req, res, file, pathname)
if (MIME[extname(pathname)]) {
// a missing asset is a miss, not a page
res.writeHead(404, { 'content-type': 'text/plain' })
return res.end('not found: ' + pathname)
}
}
// prerendered page?
const prerendered = normalize(join(outDir, pathname, 'index.html'))
if (prerendered.startsWith(outDir) && await isFile(prerendered)) {
if (links.length && res.writeEarlyHints) res.writeEarlyHints({ link: links })
return sendFile(req, res, prerendered, pathname, { link: links.join(', ') })
}
// live page: hints first, then stream
try {
const app = resolveApp(mod, meta.entry)
const state = {}
if (links.length && res.writeEarlyHints) {
res.writeEarlyHints({ link: links })
}
// first chunk before the head: stores + the first render slice have
// run by then, so state.title is populated
const reader = app.toStream(req.url, state).getReader()
const first = await reader.read()
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
link: links.join(', ')
})
res.write(documentHead({ title: state.title || meta.title, ...meta.assets }))
if (!first.done) res.write(first.value)
for (;;) {
const { done, value } = await reader.read()
if (done) break
res.write(value)
}
res.end(documentTail(state))
} catch (err) {
console.error(err)
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' })
res.end('bankai: render error')
}
}
let server
if (h2) {
const { key, cert } = await localCert(outDir)
server = http2.createSecureServer({ key, cert, allowHTTP1: true }, handler)
} else {
server = http.createServer(handler)
}
await new Promise((resolveListen) => server.listen(port, resolveListen))
return {
server,
port: server.address().port,
origin: `${h2 ? 'https' : 'http'}://localhost:${server.address().port}`,
close: () => new Promise((resolveClose) => server.close(resolveClose))
}
}
// self-signed localhost cert, generated once via openssl and cached
// alongside the build (never commit dist)
async function localCert (outDir) {
const dir = join(outDir, '.bankai-cert')
const keyPath = join(dir, 'key.pem')
const certPath = join(dir, 'cert.pem')
if (!(await isFile(keyPath)) || !(await isFile(certPath))) {
await mkdir(dir, { recursive: true })
await execFileP('openssl', [
'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '365',
'-subj', '/CN=localhost',
'-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1',
'-keyout', keyPath, '-out', certPath
])
}
return {
key: await readFile(keyPath),
cert: await readFile(certPath)
}
}
async function isFile (path) {
try {
return (await stat(path)).isFile()
} catch (e) {
return false
}
}
async function sendFile (req, res, file, pathname, extraHeaders = {}) {
const headers = {
...extraHeaders,
'content-type': MIME[extname(file)] || 'application/octet-stream',
// hashed build assets are immutable; everything else revalidates
'cache-control': pathname.startsWith('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache',
vary: 'accept-encoding'
}
// negotiate precompressed siblings written at build time
const accepted = String(req.headers['accept-encoding'] || '')
for (const [encoding, ext] of [['br', '.br'], ['gzip', '.gz']]) {
if (accepted.includes(encoding) && await isFile(file + ext)) {
headers['content-encoding'] = encoding
res.writeHead(200, headers)
createReadStream(file + ext).pipe(res)
return
}
}
res.writeHead(200, headers)
createReadStream(file).pipe(res)
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@uhhm/bankai",
"version": "8.0.0",
"description": "The isomorphic choo compiler & server: one command, streaming SSR, zero config",
"type": "module",
"bin": {
"bankai": "./cli.js"
},
"exports": {
".": "./index.js"
},
"files": [
"cli.js",
"index.js",
"lib"
],
"engines": {
"node": ">=24"
},
"dependencies": {
"vite": "^8.0.0"
},
"repository": "https://project.uhhm.no/uhhm/buuh",
"keywords": [
"choo",
"bankai",
"ssr",
"streaming",
"compiler"
],
"license": "MIT"
}
+166
View File
@@ -0,0 +1,166 @@
// The full production pipeline against the real counter example:
// build → artifacts on disk → serve → 103 Early Hints, streamed SSR,
// filtered initialState, precompressed asset negotiation.
import { test, before, after } from 'node:test'
import assert from 'node:assert'
import http from 'node:http'
import { access, mkdtemp, rm } from 'node:fs/promises'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import build from '../lib/build.js'
import serve from '../lib/serve.js'
const repo = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
const entry = join(repo, 'examples', 'counter', 'app.js')
let outDir, meta, prerendered, srv
before(async () => {
outDir = await mkdtemp(join(tmpdir(), 'bankai-test-'))
;({ meta, prerendered } = await build({ entry, outDir, title: 'counter', prerender: ['/'] }))
srv = await serve({ outDir, port: 0 })
})
after(async () => {
await srv?.close()
if (outDir) await rm(outDir, { recursive: true, force: true })
})
function get (path, headers = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: 'localhost', port: srv.port, path, headers }, (res) => {
const chunks = []
res.on('data', (d) => chunks.push(d))
res.on('end', () => resolve({
status: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks),
interim: req.interim
}))
})
req.interim = []
req.on('information', (info) => req.interim.push(info))
req.on('error', reject)
req.end()
})
}
test('build produced manifest, metadata and precompressed assets', async () => {
await access(join(outDir, '.vite', 'manifest.json'))
await access(join(outDir, 'bankai.json'))
assert.strictEqual(meta.assets.scripts.length, 1, 'one entry chunk')
const chunk = meta.assets.scripts[0]
await access(join(outDir, chunk.slice(1)))
await access(join(outDir, chunk.slice(1) + '.br'))
await access(join(outDir, chunk.slice(1) + '.gz'))
})
test('the style.css convention bundles css the server never imports', async () => {
assert.strictEqual(meta.assets.css.length, 1, 'extracted css asset')
const res = await get('/some/page')
const text = res.body.toString()
assert.match(text, /<link rel="stylesheet" href="\/assets\/.*\.css">/)
assert.match(String(res.headers.link), /as=style/, 'stylesheet in the Link header')
})
test('prerendered routes are written and served statically with hints', async () => {
assert.deepStrictEqual(prerendered, ['/'])
await access(join(outDir, 'index.html'))
const res = await get('/')
assert.strictEqual(res.status, 200)
assert.match(res.body.toString(), /count is 0/)
assert.strictEqual(res.interim[0]?.statusCode, 103, 'prerendered pages still send Early Hints')
})
test('the SSR <title> comes from state (DOMTitleChange during render)', async () => {
const res = await get('/some/page')
assert.match(res.body.toString(), /<title>count is 0<\/title>/)
})
test('live pages get 103 Early Hints carrying the route assets', async () => {
const res = await get('/some/page')
assert.strictEqual(res.interim.length, 1, 'one interim response')
assert.strictEqual(res.interim[0].statusCode, 103)
assert.match(String(res.interim[0].headers.link), /rel=modulepreload/)
assert.match(String(res.headers.link), /rel=modulepreload/, 'Link header repeated on the final response')
})
test('live pages stream SSR html with a filtered initialState tail', async () => {
const res = await get('/some/page')
const text = res.body.toString()
assert.strictEqual(res.status, 200)
assert.match(text, /<h1>count is 0<\/h1>/, 'server-rendered view')
assert.match(text, /<script type="module" src="\/assets\//, 'client entry wired')
assert.match(text, /window\.initialState=/, 'state serialized')
assert.ok(!/initialState=.*"events"/.test(text), 'internals filtered from state')
})
test('h2: HTTP/2 server sends Early Hints and streams the page', async (t) => {
const { default: http2 } = await import('node:http2')
let h2srv
try {
h2srv = await serve({ outDir, port: 0, h2: true })
} catch (err) {
t.skip('openssl unavailable: ' + err.message)
return
}
const session = http2.connect(`https://localhost:${h2srv.port}`, { rejectUnauthorized: false })
const result = await new Promise((resolve, reject) => {
const req = session.request({ ':path': '/some/page' })
let interim = null
let status = null
let body = ''
req.on('headers', (headers) => { interim = headers })
req.on('response', (headers) => { status = headers[':status'] })
req.on('data', (d) => { body += d })
req.on('end', () => resolve({ interim, status, body }))
req.on('error', reject)
req.end()
})
session.close()
await h2srv.close()
assert.strictEqual(result.status, 200)
assert.strictEqual(result.interim?.[':status'], 103, '103 interim over h2')
assert.match(String(result.interim?.link), /rel=modulepreload/)
assert.match(result.body, /count is 0/)
})
test('hashed assets are immutable and served precompressed on request', async () => {
const chunk = meta.assets.scripts[0]
const plain = await get(chunk)
assert.strictEqual(plain.status, 200)
assert.match(plain.headers['content-type'], /javascript/)
assert.match(plain.headers['cache-control'], /immutable/)
assert.strictEqual(plain.headers['content-encoding'], undefined)
const br = await get(chunk, { 'accept-encoding': 'br, gzip' })
assert.strictEqual(br.headers['content-encoding'], 'br')
assert.ok(br.body.length < plain.body.length, 'brotli variant is smaller')
const gz = await get(chunk, { 'accept-encoding': 'gzip' })
assert.strictEqual(gz.headers['content-encoding'], 'gzip')
})
test('the service worker builds with the precache list injected', async () => {
const res = await get('/sw.js')
assert.strictEqual(res.status, 200)
const sw = res.body.toString()
assert.ok(!sw.includes('__BANKAI_ASSETS__'), 'define was applied')
assert.ok(sw.includes(meta.assets.scripts[0]), 'hashed entry chunk in the precache list')
assert.match(sw, /[`"']\/[`"']\s*,/, 'root page precached (any quote style the minifier picks)')
})
test('missing assets 404; extensionless paths fall through to SSR', async () => {
const miss = await get('/assets/nope-not-real.js')
assert.strictEqual(miss.status, 404)
const page = await get('/some/page')
assert.strictEqual(page.status, 200, 'wildcard route rendered')
assert.match(page.body.toString(), /count is 0/)
})
+55
View File
@@ -0,0 +1,55 @@
import { test } from 'node:test'
import assert from 'node:assert'
import { documentHead, documentTail, serializeState, assetLinks } from '../lib/document.js'
test('documentHead renders title, css, preloads and module scripts', () => {
const head = documentHead({
title: 'a < b',
css: ['/assets/x.css'],
modulepreload: ['/assets/dep.js'],
scripts: ['/assets/main.js']
})
assert.match(head, /<title>a &lt; b<\/title>/)
assert.match(head, /<link rel="stylesheet" href="\/assets\/x.css">/)
assert.match(head, /<link rel="modulepreload" href="\/assets\/dep.js">/)
assert.match(head, /<script type="module" src="\/assets\/main.js"><\/script>/)
assert.match(head, /^<!doctype html>/)
})
test('serializeState blocks script breakouts and drops recomputed internals', () => {
const json = serializeState({
events: { RENDER: 'render' },
cache: null,
prefetch: [],
count: 3,
evil: '</script><script>alert(1)</script>',
nested: { events: 'keep me' }
})
assert.ok(!json.includes('</script'), 'no closing-script breakout')
const parsed = JSON.parse(json.replace(/\\u003c/g, '<'))
assert.strictEqual(parsed.count, 3)
assert.strictEqual(parsed.events, undefined, 'top-level events dropped')
assert.strictEqual(parsed.cache, undefined)
assert.strictEqual(parsed.prefetch, undefined)
assert.strictEqual(parsed.nested.events, 'keep me', 'only top-level internals dropped')
})
test('documentTail is an inline script plus the html close', () => {
const tail = documentTail({ count: 1 })
assert.match(tail, /<script>window.initialState=\{"count":1\}<\/script>/)
assert.match(tail, /<\/html>/)
})
test('assetLinks builds Early Hints link values', () => {
const links = assetLinks({
scripts: ['/assets/main.js'],
modulepreload: ['/assets/dep.js'],
css: ['/assets/x.css']
})
assert.deepStrictEqual(links, [
'</assets/main.js>; rel=modulepreload',
'</assets/dep.js>; rel=modulepreload',
'</assets/x.css>; rel=preload; as=style'
])
})
+157
View File
@@ -0,0 +1,157 @@
// Ported from nanocomponent 6.6.0 (MIT) — https://github.com/choojs/nanocomponent
// Native DOM components: render once, morph on update, lifecycle hooks via
// onload. In v8 this class is also the intended island/hydration boundary.
import morph from '@uhhm/buuh-html/morph'
import nanotiming from '@uhhm/buuh/timing'
import onload, { KEY_ATTR } from './onload.js'
function makeID () {
return 'ncid-' + Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
}
export default class Component {
static makeID = makeID
constructor (name) {
this._hasWindow = typeof window !== 'undefined'
this._id = null // represents the id of the root node
this._ncID = null // internal component id
this._olID = null
this._proxy = null
this._loaded = false // Used to debounce on-load when child-reordering
this._rootNodeName = null
this._name = name || 'component'
this._rerender = false
this._handleLoad = this._handleLoad.bind(this)
this._handleUnload = this._handleUnload.bind(this)
this._arguments = []
}
get element () {
if (!this._hasWindow) return undefined
const el = document.getElementById(this._id)
if (el) return el.dataset.nanocomponent === this._ncID ? el : undefined
}
render (...args) {
const renderTiming = nanotiming(this._name + '.render')
let el
if (!this._hasWindow) {
const createTiming = nanotiming(this._name + '.create')
el = this.createElement(...args)
createTiming()
renderTiming()
return el
} else if (this.element) {
el = this.element // retain reference, as the ID might change on render
const updateTiming = nanotiming(this._name + '.update')
const shouldUpdate = this._rerender || this.update(...args)
updateTiming()
if (this._rerender) this._rerender = false
if (shouldUpdate) {
const desiredHtml = this._handleRender(args)
const morphTiming = nanotiming(this._name + '.morph')
morph(el, desiredHtml)
morphTiming()
if (this.afterupdate) this.afterupdate(el)
}
if (!this._proxy) { this._proxy = this._createProxy() }
renderTiming()
return this._proxy
} else {
this._reset()
el = this._handleRender(args)
if (this.beforerender) this.beforerender(el)
if (this.load || this.unload || this.afterreorder) {
onload(el, this._handleLoad, this._handleUnload, this._ncID)
this._olID = el.getAttribute(KEY_ATTR)
}
renderTiming()
return el
}
}
rerender () {
if (!this.element) throw new Error('component: cant rerender on an unmounted dom node')
this._rerender = true
this.render(...this._arguments)
}
_handleRender (args) {
const createElementTiming = nanotiming(this._name + '.createElement')
const el = this.createElement(...args)
createElementTiming()
if (!this._rootNodeName) this._rootNodeName = el.nodeName
if (!(el instanceof window.Element)) {
throw new Error('component: createElement should return a single DOM node')
}
if (this._rootNodeName !== el.nodeName) {
throw new Error('component: root node types cannot differ between re-renders')
}
this._arguments = args
return this._brandNode(this._ensureID(el))
}
_createProxy () {
const proxy = document.createElement(this._rootNodeName)
const self = this
this._brandNode(proxy)
proxy.id = this._id
proxy.setAttribute('data-proxy', '')
proxy.isSameNode = function (el) {
return (el && el.dataset.nanocomponent === self._ncID)
}
return proxy
}
_reset () {
this._ncID = makeID()
this._olID = null
this._id = null
this._proxy = null
this._rootNodeName = null
}
_brandNode (node) {
node.setAttribute('data-nanocomponent', this._ncID)
if (this._olID) node.setAttribute(KEY_ATTR, this._olID)
return node
}
_ensureID (node) {
if (node.id) this._id = node.id
else node.id = this._id = this._ncID
// Update proxy node ID if it changed
if (this._proxy && this._proxy.id !== this._id) this._proxy.id = this._id
return node
}
_handleLoad (el) {
if (this._loaded) {
if (this.afterreorder) this.afterreorder(el)
return // Debounce child-reorders
}
this._loaded = true
if (this.load) this.load(el)
}
_handleUnload (el) {
if (this.element) return // Debounce child-reorders
this._loaded = false
if (this.unload) this.unload(el)
}
createElement () {
throw new Error('component: createElement should be implemented!')
}
update () {
throw new Error('component: update should be implemented!')
}
}
export { Component, makeID }
+106
View File
@@ -0,0 +1,106 @@
// Ported from on-load 3.4.1 (MIT) — https://github.com/shama/on-load
// Fire load/unload callbacks when watched nodes enter or leave the DOM,
// driven by a single document-wide MutationObserver.
const watch = Object.create(null)
const KEY_ID = 'onloadid' + Math.random().toString(36).slice(2, 8)
const KEY_ATTR = 'data-' + KEY_ID
let INDEX = 0
if (typeof window !== 'undefined' && window.MutationObserver) {
const observer = new window.MutationObserver(function (mutations) {
if (Object.keys(watch).length < 1) return
for (let i = 0; i < mutations.length; i++) {
if (mutations[i].attributeName === KEY_ATTR) {
eachAttr(mutations[i], turnon, turnoff)
continue
}
eachMutation(mutations[i].removedNodes, turnoff)
eachMutation(mutations[i].addedNodes, turnon)
}
})
if (document.body) {
beginObserve(observer)
} else {
document.addEventListener('DOMContentLoaded', function () {
beginObserve(observer)
})
}
}
function beginObserve (observer) {
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: [KEY_ATTR]
})
}
export default function onload (el, on, off, caller) {
if (!document.body) throw new Error('onload: will not work prior to DOMContentLoaded')
on = on || function () {}
off = off || function () {}
el.setAttribute(KEY_ATTR, 'o' + INDEX)
watch['o' + INDEX] = [on, off, 0, caller]
INDEX += 1
return el
}
onload.KEY_ATTR = KEY_ATTR
onload.KEY_ID = KEY_ID
export { KEY_ATTR, KEY_ID }
function turnon (index, el) {
if (watch[index][0] && watch[index][2] === 0) {
watch[index][0](el)
watch[index][2] = 1
}
}
function turnoff (index, el) {
if (watch[index][1] && watch[index][2] === 1) {
watch[index][1](el)
watch[index][2] = 0
}
}
function eachAttr (mutation, on, off) {
const newValue = mutation.target.getAttribute(KEY_ATTR)
if (sameOrigin(mutation.oldValue, newValue)) {
watch[newValue] = watch[mutation.oldValue]
return
}
if (watch[mutation.oldValue]) {
off(mutation.oldValue, mutation.target)
}
if (watch[newValue]) {
on(newValue, mutation.target)
}
}
function sameOrigin (oldValue, newValue) {
if (!oldValue || !newValue) return false
if (!watch[oldValue] || !watch[newValue]) return false
return watch[oldValue][3] === watch[newValue][3]
}
function eachMutation (nodes, fn) {
const keys = Object.keys(watch)
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node && node.getAttribute && node.getAttribute(KEY_ATTR)) {
const onloadid = node.getAttribute(KEY_ATTR)
keys.forEach(function (k) {
if (onloadid === k) {
fn(k, node)
}
})
}
if (node.childNodes && node.childNodes.length > 0) {
eachMutation(node.childNodes, fn)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@uhhm/buuh-component",
"version": "8.0.0",
"description": "Native DOM components with lifecycle hooks \u2014 the choo island boundary",
"type": "module",
"exports": {
".": "./index.js",
"./onload": "./onload.js"
},
"files": [
"index.js",
"onload.js"
],
"engines": {
"node": ">=24"
},
"dependencies": {
"@uhhm/buuh": "^8.0.0",
"@uhhm/buuh-html": "^8.0.0"
},
"repository": "https://project.uhhm.no/uhhm/buuh",
"keywords": [
"component",
"dom",
"lifecycle",
"choo"
],
"license": "MIT"
}
+72
View File
@@ -0,0 +1,72 @@
// Component behavior in both worlds: plain string rendering without a DOM,
// and mount/update/morph/proxy semantics inside happy-dom.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
import serverHtml from '@uhhm/buuh-html/server'
test('renders via createElement on the server (no window)', async () => {
const { default: Component } = await import('../index.js')
class Title extends Component {
createElement (text) {
return serverHtml`<h1>${text}</h1>`
}
update () { return false }
}
const res = new Title().render('hello')
assert.strictEqual(res.toString(), '<h1>hello</h1>')
})
test('browser: render, mount, update morphs in place, proxy stands in', async () => {
const win = new Window()
globalThis.window = win
globalThis.document = win.document
// module registry caches per specifier; component/index.js re-evaluates
// _hasWindow per instance, so importing after globals are set is enough
const { default: Component } = await import('../index.js?browser')
const { default: html } = await import('@uhhm/buuh-html/browser')
const { default: morph } = await import('@uhhm/buuh-html/morph')
class Counter extends Component {
createElement (n) {
this.n = n
return html`<div>count ${n}</div>`
}
update (n) {
return n !== this.n
}
}
const counter = new Counter('counter')
const el = counter.render(0)
assert.ok(el.getAttribute('data-nanocomponent'), 'root node branded')
document.body.appendChild(el)
assert.strictEqual(counter.element, el, 'element getter finds mounted node')
// unchanged args: update() false, no morph, proxy returned
const proxy = counter.render(0)
assert.ok(proxy.hasAttribute('data-proxy'), 'returns proxy while mounted')
assert.ok(proxy.isSameNode(el), 'proxy claims identity of the mounted node')
// changed args: update() true, mounted node morphs in place
counter.render(1)
assert.strictEqual(el.textContent, 'count 1', 'mounted node updated in place')
// and the proxy plays correctly with nanomorph in a parent view
const parent = (n) => {
const inner = counter.render(n)
return html`<main>${inner}</main>`
}
const treeA = parent(1)
// simulate first full-page render: mounted component moves into the tree
assert.ok(treeA.querySelector('[data-proxy]') || treeA.contains(el))
document.body.removeChild(el)
})
+391
View File
@@ -0,0 +1,391 @@
// Ported from choo 7.1.0 index.js (MIT) — https://github.com/choojs/choo
// Same API, same event flow; ESM, consolidated nano* internals in ./lib.
import morph from '@uhhm/buuh-html/morph'
import hydrate from '@uhhm/buuh-html/hydrate'
import nanotiming from './lib/timing.js'
import Nanorouter from './lib/router.js'
import Nanobus from './lib/bus.js'
import nanoquery from './lib/query.js'
import nanoraf from './lib/raf.js'
import nanohref from './lib/href.js'
import ComponentCache from './lib/cache.js'
import lazy, { PENDING } from './lib/lazy.js'
import { documentReady, scrollToAnchor } from './lib/dom.js'
import { ok, equal, notEqual } from './lib/assert.js'
export { lazy }
const HISTORY_OBJECT = {}
// state.href is for reading: decode it for humans ('/caf%C3%A9' → '/café'),
// but never throw on malformed input — keep the raw string instead.
function safeDecode (location) {
try {
return decodeURI(location).normalize('NFC')
} catch (e) {
return location
}
}
export class Choo {
constructor (opts) {
const timing = nanotiming('choo.constructor')
opts = opts || {}
equal(typeof opts, 'object', 'choo: opts should be type object')
const self = this
// define events used by choo
this._events = {
DOMCONTENTLOADED: 'DOMContentLoaded',
DOMTITLECHANGE: 'DOMTitleChange',
REPLACESTATE: 'replaceState',
PUSHSTATE: 'pushState',
NAVIGATE: 'navigate',
POPSTATE: 'popState',
RENDER: 'render'
}
// properties for internal use only
this._historyEnabled = opts.history === undefined ? true : opts.history
this._hrefEnabled = opts.href === undefined ? true : opts.href
this._hashEnabled = opts.hash === undefined ? false : opts.hash
this._hasWindow = typeof window !== 'undefined'
this._cache = opts.cache
this._loaded = false
this._stores = [ondomtitlechange]
this._tree = null
this._pendingTree = false
// state
const _state = {
events: this._events,
components: {}
}
if (this._hasWindow) {
this.state = window.initialState
? Object.assign({}, window.initialState, _state)
: _state
delete window.initialState
} else {
this.state = _state
}
// properties that are part of the API
this.router = new Nanorouter()
this.emitter = new Nanobus('choo.emit')
this.emit = this.emitter.emit.bind(this.emitter)
// listen for title changes; available even when calling .toString()
if (this._hasWindow) this.state.title = document.title
function ondomtitlechange (state) {
self.emitter.prependListener(self._events.DOMTITLECHANGE, function (title) {
equal(typeof title, 'string', 'events.DOMTitleChange: title should be type string')
state.title = title
if (self._hasWindow) document.title = title
})
}
timing()
}
route (route, handler) {
const routeTiming = nanotiming("choo.route('" + route + "')")
equal(typeof route, 'string', 'choo.route: route should be type string')
equal(typeof handler, 'function', 'choo.handler: route should be type function')
this.router.on(route, handler)
routeTiming()
}
use (cb) {
equal(typeof cb, 'function', 'choo.use: cb should be type function')
const self = this
this._stores.push(function (state) {
let msg = 'choo.use'
msg = cb.storeName ? msg + '(' + cb.storeName + ')' : msg
const endTiming = nanotiming(msg)
cb(state, self.emitter, self)
endTiming()
})
}
start () {
equal(typeof window, 'object', 'choo.start: window was not found. .start() must be called in a browser, use .toString() if running in Node')
const startTiming = nanotiming('choo.start')
const self = this
if (this._historyEnabled) {
this.emitter.prependListener(this._events.NAVIGATE, function () {
self._matchRoute(self.state)
if (self._loaded) {
self.emitter.emit(self._events.RENDER)
setTimeout(scrollToAnchor.bind(null, window.location.hash), 0)
}
})
this.emitter.prependListener(this._events.POPSTATE, function () {
self.emitter.emit(self._events.NAVIGATE)
})
this.emitter.prependListener(this._events.PUSHSTATE, function (href) {
equal(typeof href, 'string', 'events.pushState: href should be type string')
window.history.pushState(HISTORY_OBJECT, null, href)
self.emitter.emit(self._events.NAVIGATE)
})
this.emitter.prependListener(this._events.REPLACESTATE, function (href) {
equal(typeof href, 'string', 'events.replaceState: href should be type string')
window.history.replaceState(HISTORY_OBJECT, null, href)
self.emitter.emit(self._events.NAVIGATE)
})
window.onpopstate = function () {
self.emitter.emit(self._events.POPSTATE)
}
if (self._hrefEnabled) {
nanohref(function (location) {
const href = location.href
const hash = location.hash
if (href === window.location.href) {
if (!self._hashEnabled && hash) scrollToAnchor(hash)
return
}
self.emitter.emit(self._events.PUSHSTATE, href)
})
}
}
this._setCache(this.state)
this._matchRoute(this.state)
this._stores.forEach(function (initStore) {
initStore(self.state)
})
let tree = this._prerender(this.state)
ok(tree, 'choo.start: no valid DOM node returned for location ' + this.state.href)
if (tree === PENDING) {
// lazy route still loading and no loading view: hold the spot with
// a placeholder; the first real render replaces it wholesale
tree = document.createElement('div')
tree.setAttribute('data-choo-pending', '')
this._pendingTree = true
}
this._tree = tree
this.emitter.prependListener(self._events.RENDER, nanoraf(function () {
const renderTiming = nanotiming('choo.render')
const newTree = self._prerender(self.state)
ok(newTree, 'choo.render: no valid DOM node returned for location ' + self.state.href)
if (newTree === PENDING) {
// lazy route still loading: keep whatever is on screen
renderTiming()
return
}
if (self._pendingTree) {
// placeholder → first real view: replace, don't morph
const old = self._tree
self._tree = newTree
if (old.parentNode) old.parentNode.replaceChild(newTree, old)
self._pendingTree = false
renderTiming()
return
}
equal(self._tree.nodeName, newTree.nodeName, 'choo.render: The target node <' +
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
newTree.nodeName.toLowerCase() + '>.')
const morphTiming = nanotiming('choo.morph')
morph(self._tree, newTree)
morphTiming()
renderTiming()
}))
documentReady(function () {
self.emitter.emit(self._events.DOMCONTENTLOADED)
self._loaded = true
})
startTiming()
return this._tree
}
mount (selector) {
const mountTiming = nanotiming("choo.mount('" + selector + "')")
if (typeof window !== 'object') {
ok(typeof selector === 'string', 'choo.mount: selector should be type String')
this.selector = selector
mountTiming()
return this
}
ok(typeof selector === 'string' || typeof selector === 'object', 'choo.mount: selector should be type String or HTMLElement')
const self = this
documentReady(function () {
const renderTiming = nanotiming('choo.render')
const newTree = self.start()
if (typeof selector === 'string') {
self._tree = document.querySelector(selector)
} else {
self._tree = selector
}
ok(self._tree, 'choo.mount: could not query selector: ' + selector)
equal(self._tree.nodeName, newTree.nodeName, 'choo.mount: The target node <' +
self._tree.nodeName.toLowerCase() + '> is not the same type as the new node <' +
newTree.nodeName.toLowerCase() + '>.')
// First render adopts the existing (usually server-rendered) DOM:
// matching nodes are left in place, and any server/client markup
// disagreement is reported before the client render wins.
const morphTiming = nanotiming('choo.morph')
hydrate(self._tree, newTree, {
onMismatch: function (diff) {
console.warn(
'choo.mount: server and client markup differ at ' + diff.path +
' (' + diff.reason + '): server rendered ' + diff.server +
', client rendered ' + diff.client +
'. The client version wins; fix the view so both sides agree.'
)
}
})
morphTiming()
renderTiming()
})
mountTiming()
}
toString (location, state) {
state = state || {}
state.components = state.components || {}
state.events = Object.assign({}, state.events, this._events)
notEqual(typeof window, 'object', 'choo.mount: window was found. .toString() must be called in Node, use .start() or .mount() if running in the browser')
equal(typeof location, 'string', 'choo.toString: location should be type string')
equal(typeof state, 'object', 'choo.toString: state should be type object')
this._setCache(state)
this._matchRoute(state, location)
this.emitter.removeAllListeners()
state.prefetch = []
this._stores.forEach(function (initStore) {
initStore(state)
})
ok(state.prefetch.length === 0, 'choo.toString: stores requested prefetch data — render this route with toStream() instead')
const html = this._prerender(state)
ok(html, 'choo.toString: no valid value returned for the route ' + location)
ok(html !== PENDING, 'choo.toString: the route ' + location + ' loads its view lazily — render it with toStream() instead')
ok(!Array.isArray(html), 'choo.toString: return value was an array for the route ' + location)
return typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
}
// Streaming server render on web-standard streams: returns a
// ReadableStream of UTF-8 bytes. Compared to toString() it can wait —
// for store prefetch promises (pushed into state.prefetch during store
// init) and for lazy route views — and it flushes template output
// progressively: everything before an async hole is sent immediately,
// the rest follows as each promise resolves, in document order.
// Runtime-portable by construction: pass it to `new Response(stream)`
// on web-standard servers, or `Readable.fromWeb(stream).pipe(res)` on
// Node's http.
toStream (location, state) {
state = state || {}
state.components = state.components || {}
state.events = Object.assign({}, state.events, this._events)
notEqual(typeof window, 'object', 'choo.toStream: window was found. .toStream() must be called in Node, use .start() or .mount() if running in the browser')
equal(typeof location, 'string', 'choo.toStream: location should be type string')
const self = this
this._setCache(state)
this._matchRoute(state, location)
this.emitter.removeAllListeners()
state.prefetch = []
this._stores.forEach(function (initStore) {
initStore(state)
})
const prefetch = state.prefetch
const encoder = new TextEncoder()
return new ReadableStream({
async start (controller) {
try {
if (prefetch.length) await Promise.all(prefetch)
if (self._handler.__lazy) await self._handler.load()
const html = self._prerender(state)
ok(html, 'choo.toStream: no valid value returned for the route ' + location)
ok(!Array.isArray(html), 'choo.toStream: return value was an array for the route ' + location)
if (typeof html[Symbol.asyncIterator] === 'function') {
for await (const chunk of html) {
controller.enqueue(encoder.encode(chunk))
}
} else {
const str = typeof html.outerHTML === 'string' ? html.outerHTML : html.toString()
controller.enqueue(encoder.encode(str))
}
controller.close()
} catch (err) {
controller.error(err)
}
}
})
}
_matchRoute (state, locationOverride) {
let location, queryString
if (locationOverride) {
location = locationOverride.replace(/\?.+$/, '').replace(/\/$/, '')
if (!this._hashEnabled) location = location.replace(/#.+$/, '')
queryString = locationOverride
} else {
location = window.location.pathname.replace(/\/$/, '')
if (this._hashEnabled) location += window.location.hash.replace(/^#/, '/')
queryString = window.location.search
}
const matched = this.router.match(location)
this._handler = matched.cb
state.href = safeDecode(location)
state.query = nanoquery(queryString)
state.route = matched.route
state.params = matched.params
}
_prerender (state) {
const routeTiming = nanotiming("choo.prerender('" + state.route + "')")
const res = this._handler(state, this.emit)
routeTiming()
return res
}
_setCache (state) {
const cache = new ComponentCache(state, this.emitter.emit.bind(this.emitter), this._cache)
state.cache = renderComponent
function renderComponent (Component, id, ...args) {
equal(typeof Component, 'function', 'choo.state.cache: Component should be type function')
return cache.render(Component, id, ...args)
}
// When the state gets stringified, make sure `state.cache` isn't
// stringified too.
renderComponent.toJSON = function () {
return null
}
}
}
// Callable with or without `new`, matching choo v7's `choo()` usage.
export default function choo (opts) {
return new Choo(opts)
}
+19
View File
@@ -0,0 +1,19 @@
// Minimal assertions in the spirit of nanoassert (MIT).
// These guard the public API in development; a production bundler can strip
// them by replacing this module with no-ops.
export function ok (value, message) {
if (!value) throw new Error(message || 'assertion failed')
}
/* eslint-disable eqeqeq */
export function equal (a, b, message) {
if (a != b) throw new Error(message || `${a} != ${b}`)
}
export function notEqual (a, b, message) {
if (a == b) throw new Error(message || `${a} == ${b}`)
}
/* eslint-enable eqeqeq */
export default ok
+161
View File
@@ -0,0 +1,161 @@
// Ported from nanobus 4.5.0 (MIT) — https://github.com/choojs/nanobus
// Event emitter with a '*' wildcard channel and nanotiming instrumentation.
import nanotiming from './timing.js'
import { ok, equal } from './assert.js'
function assertEventName (eventName, method) {
ok(
typeof eventName === 'string' || typeof eventName === 'symbol',
`nanobus.${method}: eventName should be type string or symbol`
)
}
export default class Nanobus {
constructor (name) {
this._name = name || 'nanobus'
this._starListeners = []
this._listeners = {}
}
emit (eventName, ...data) {
assertEventName(eventName, 'emit')
const emitTiming = nanotiming(this._name + "('" + eventName.toString() + "')")
const listeners = this._listeners[eventName]
if (listeners && listeners.length > 0) {
this._emit(this._listeners[eventName], data)
}
if (this._starListeners.length > 0) {
this._emit(this._starListeners, eventName, data, emitTiming.uuid)
}
emitTiming()
return this
}
on (eventName, listener) {
assertEventName(eventName, 'on')
equal(typeof listener, 'function', 'nanobus.on: listener should be type function')
if (eventName === '*') {
this._starListeners.push(listener)
} else {
if (!this._listeners[eventName]) this._listeners[eventName] = []
this._listeners[eventName].push(listener)
}
return this
}
addListener (eventName, listener) {
return this.on(eventName, listener)
}
prependListener (eventName, listener) {
assertEventName(eventName, 'prependListener')
equal(typeof listener, 'function', 'nanobus.prependListener: listener should be type function')
if (eventName === '*') {
this._starListeners.unshift(listener)
} else {
if (!this._listeners[eventName]) this._listeners[eventName] = []
this._listeners[eventName].unshift(listener)
}
return this
}
once (eventName, listener) {
assertEventName(eventName, 'once')
equal(typeof listener, 'function', 'nanobus.once: listener should be type function')
const self = this
this.on(eventName, function once (...args) {
listener.apply(self, args)
self.removeListener(eventName, once)
})
return this
}
prependOnceListener (eventName, listener) {
assertEventName(eventName, 'prependOnceListener')
equal(typeof listener, 'function', 'nanobus.prependOnceListener: listener should be type function')
const self = this
this.prependListener(eventName, function once (...args) {
listener.apply(self, args)
self.removeListener(eventName, once)
})
return this
}
removeListener (eventName, listener) {
assertEventName(eventName, 'removeListener')
equal(typeof listener, 'function', 'nanobus.removeListener: listener should be type function')
if (eventName === '*') {
this._starListeners = this._starListeners.slice()
return remove(this._starListeners, listener)
} else {
if (typeof this._listeners[eventName] !== 'undefined') {
this._listeners[eventName] = this._listeners[eventName].slice()
}
return remove(this._listeners[eventName], listener)
}
function remove (arr, listener) {
if (!arr) return
const index = arr.indexOf(listener)
if (index !== -1) {
arr.splice(index, 1)
return true
}
}
}
removeAllListeners (eventName) {
if (eventName) {
if (eventName === '*') {
this._starListeners = []
} else {
this._listeners[eventName] = []
}
} else {
this._starListeners = []
this._listeners = {}
}
return this
}
listeners (eventName) {
const listeners = eventName !== '*'
? this._listeners[eventName]
: this._starListeners
return listeners ? listeners.slice() : []
}
_emit (arr, eventName, data, uuid) {
if (typeof arr === 'undefined') return
if (arr.length === 0) return
if (data === undefined) {
data = eventName
eventName = null
}
if (eventName) {
if (uuid !== undefined) {
data = [eventName].concat(data, uuid)
} else {
data = [eventName].concat(data)
}
}
// Take a copy in case a listener mutates the array while we iterate.
const listeners = arr.slice()
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i]
listener.apply(listener, data)
}
}
}
+150
View File
@@ -0,0 +1,150 @@
// Component instance cache for state.cache().
// LRU ported from nanolru 1.0.0 (MIT) — https://github.com/s3ththompson/nanolru
// Cache wrapper ported from choo 7.1.0 component/cache.js (MIT)
import { ok, equal } from './assert.js'
export class LRU {
constructor (opts) {
if (typeof opts === 'number') opts = { max: opts }
if (!opts) opts = {}
this.cache = {}
this.head = this.tail = null
this.length = 0
this.max = opts.max || 1000
}
get keys () {
return Object.keys(this.cache)
}
clear () {
this.cache = {}
this.head = this.tail = null
this.length = 0
}
remove (key) {
if (typeof key !== 'string') key = '' + key
if (!Object.hasOwn(this.cache, key)) return
const element = this.cache[key]
delete this.cache[key]
this._unlink(key, element.prev, element.next)
return element.value
}
_unlink (key, prev, next) {
this.length--
if (this.length === 0) {
this.head = this.tail = null
} else {
if (this.head === key) {
this.head = prev
this.cache[this.head].next = null
} else if (this.tail === key) {
this.tail = next
this.cache[this.tail].prev = null
} else {
this.cache[prev].next = next
this.cache[next].prev = prev
}
}
}
peek (key) {
if (!Object.hasOwn(this.cache, key)) return
return this.cache[key].value
}
set (key, value) {
if (typeof key !== 'string') key = '' + key
let element
if (Object.hasOwn(this.cache, key)) {
element = this.cache[key]
element.value = value
// If it's already the head, there's nothing more to do:
if (key === this.head) return value
this._unlink(key, element.prev, element.next)
} else {
element = { value, next: null, prev: null }
this.cache[key] = element
// Eviction is only possible if the key didn't already exist:
if (this.length === this.max) this.evict()
}
this.length++
element.next = null
element.prev = this.head
if (this.head) this.cache[this.head].next = key
this.head = key
if (!this.tail) this.tail = key
return value
}
get (key) {
if (typeof key !== 'string') key = '' + key
if (!Object.hasOwn(this.cache, key)) return
const element = this.cache[key]
if (this.head !== key) {
if (key === this.tail) {
this.tail = element.next
this.cache[this.tail].prev = null
} else {
// Set prev.next -> element.next:
this.cache[element.prev].next = element.next
}
// Set element.next.prev -> element.prev:
this.cache[element.next].prev = element.prev
// Element is the new head
this.cache[this.head].next = key
element.prev = this.head
element.next = null
this.head = key
}
return element.value
}
evict () {
if (!this.tail) return
this.remove(this.tail)
}
}
export default class ComponentCache {
constructor (state, emit, lru) {
equal(typeof state, 'object', 'ComponentCache: state should be type object')
equal(typeof emit, 'function', 'ComponentCache: emit should be type function')
if (typeof lru === 'number') this.cache = new LRU(lru)
else this.cache = lru || new LRU(100)
this.state = state
this.emit = emit
}
// Get & create component instances.
render (Component, id, ...args) {
equal(typeof Component, 'function', 'ComponentCache.render: Component should be type function')
ok(typeof id === 'string' || typeof id === 'number', 'ComponentCache.render: id should be type string or type number')
let el = this.cache.get(id)
if (!el) {
el = new Component(id, this.state, this.emit, ...args)
this.cache.set(id, el)
}
return el
}
}
+25
View File
@@ -0,0 +1,25 @@
// Ported from document-ready 2.0.1 and scroll-to-anchor 1.0.0 (MIT)
// https://github.com/bendrucker/document-ready — https://github.com/yoshuawuyts/scroll-to-anchor
import { notEqual } from './assert.js'
export function documentReady (callback) {
notEqual(typeof document, 'undefined', 'documentReady only runs in the browser')
const state = document.readyState
if (state === 'complete' || state === 'interactive') {
return setTimeout(callback, 0)
}
document.addEventListener('DOMContentLoaded', function onLoad () {
callback()
})
}
export function scrollToAnchor (anchor) {
if (anchor) {
try {
const el = document.querySelector(anchor)
if (el) el.scrollIntoView(true)
} catch (e) {}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Ported from nanohref 3.1.0 (MIT) — https://github.com/choojs/nanohref
// Intercept same-origin anchor clicks for client-side navigation.
import { equal, notEqual } from './assert.js'
const safeExternalLink = /(noopener|noreferrer) (noopener|noreferrer)/
const protocolLink = /^[\w-_]+:/
export default function href (cb, root) {
notEqual(typeof window, 'undefined', 'nanohref: expected window to exist')
root = root || window.document
equal(typeof cb, 'function', 'nanohref: cb should be type function')
equal(typeof root, 'object', 'nanohref: root should be type object')
window.addEventListener('click', function (e) {
if ((e.button && e.button !== 0) ||
e.ctrlKey || e.metaKey || e.altKey || e.shiftKey ||
e.defaultPrevented) return
const anchor = (function traverse (node) {
if (!node || node === root) return
if (node.localName !== 'a' || node.href === undefined) {
return traverse(node.parentNode)
}
return node
})(e.target)
if (!anchor) return
if (window.location.protocol !== anchor.protocol ||
window.location.hostname !== anchor.hostname ||
window.location.port !== anchor.port ||
anchor.hasAttribute('data-nanohref-ignore') ||
anchor.hasAttribute('download') ||
(anchor.getAttribute('target') === '_blank' &&
safeExternalLink.test(anchor.getAttribute('rel'))) ||
protocolLink.test(anchor.getAttribute('href'))) return
e.preventDefault()
cb(anchor)
})
}
+53
View File
@@ -0,0 +1,53 @@
// Lazy (async) routes — the v8 answer to choojs/choo#653.
//
// A lazy route handler loads its view on first match via a native dynamic
// import (or any promise-returning loader) and caches it forever after.
// In the browser, while the view is loading, the handler renders the
// optional loading view — or the PENDING sentinel, which tells choo to
// keep whatever is already on screen. When the module arrives the wrapper
// emits a render and the real view morphs in. On the server, toStream()
// awaits load() before rendering; toString() refuses lazy routes loudly
// (the missing server story is what stalled #653 — here both halves ship
// together).
import { equal } from './assert.js'
// Sentinel: "keep the current tree, a view is on its way"
export const PENDING = { __chooPending: true }
export default function lazy (loader, loading) {
equal(typeof loader, 'function', 'choo.lazy: loader should be type function')
let view = null
let promise = null
function load () {
if (!promise) {
promise = Promise.resolve(loader()).then(function (mod) {
view = (mod && mod.default) || mod
equal(typeof view, 'function', 'choo.lazy: loader should resolve to a view function (module default export)')
return view
})
}
return promise
}
function lazyView (state, emit) {
if (view) return view(state, emit)
load().then(function () {
emit(state.events.RENDER)
}, function (err) {
// surface load failures instead of hanging on the loading view
emit('error', err)
throw err
})
if (loading) return loading(state, emit)
return PENDING
}
lazyView.__lazy = true
lazyView.load = load
return lazyView
}
+23
View File
@@ -0,0 +1,23 @@
// Replaces nanoquery 1.3.0 (MIT) with the platform's URLSearchParams.
// Semantics preserved: returns a plain object; repeated keys become arrays.
import { equal } from './assert.js'
export default function nanoquery (url) {
equal(typeof url, 'string', 'nanoquery: url should be type string')
const query = {}
const index = url.indexOf('?')
if (index === -1) return query
const params = new URLSearchParams(url.slice(index + 1))
for (const [key, value] of params) {
if (Object.hasOwn(query, key)) {
if (Array.isArray(query[key])) query[key].push(value)
else query[key] = [query[key], value]
} else {
query[key] = value
}
}
return query
}
+31
View File
@@ -0,0 +1,31 @@
// Ported from nanoraf 3.1.0 (MIT) — https://github.com/choojs/nanoraf
// Only call requestAnimationFrame when needed.
import { ok, equal } from './assert.js'
export default function nanoraf (render, raf) {
equal(typeof render, 'function', 'nanoraf: render should be a function')
ok(typeof raf === 'function' || typeof raf === 'undefined', 'nanoraf: raf should be a function or undefined')
// Wrap rather than alias: calling an extracted requestAnimationFrame
// with no receiver throws 'Illegal invocation' in strict-mode ESM
// (the CJS original survived only thanks to sloppy-mode this-patching).
if (!raf) raf = (cb) => globalThis.requestAnimationFrame(cb)
let redrawScheduled = false
let args = null
return function frame (...frameArgs) {
if (args === null && !redrawScheduled) {
redrawScheduled = true
raf(function redraw () {
redrawScheduled = false
const _args = args
args = null
render(..._args)
})
}
args = frameArgs
}
}
+251
View File
@@ -0,0 +1,251 @@
// Ported from nanorouter 4.0.0 and wayfarer 7.0.1 with its trie (MIT)
// https://github.com/choojs/nanorouter — https://github.com/yoshuawuyts/wayfarer
// Consolidated into a single module: trie-based router with :params and
// * wildcards, plus URL normalization on top.
//
// Normalization differs deliberately from v7: locations are parsed with the
// WHATWG URL parser and each path segment is percent-decoded exactly once,
// falling back to the raw segment instead of throwing on malformed input
// (v7's decodeURI crashed on a literal '%'). Both route definitions and
// incoming segments are NFC-normalized so 'café' matches 'café' regardless
// of how the é was composed.
import { ok, equal, notEqual } from './assert.js'
function has (object, property) {
return Object.prototype.hasOwnProperty.call(object, property)
}
// Decode a path segment exactly once; keep the raw segment when it isn't
// valid percent-encoding. Always NFC-normalize.
export function decodeSegment (segment) {
try {
return decodeURIComponent(segment).normalize('NFC')
} catch (e) {
return segment.normalize('NFC')
}
}
class Trie {
constructor () {
this.trie = { nodes: {} }
}
// create a node on the trie at route and return it
create (route) {
equal(typeof route, 'string', 'route should be a string')
// strip leading '/' and split routes
const routes = route.replace(/^\//, '').split('/')
const self = this
function createNode (index, trie) {
const thisRoute = has(routes, index) && routes[index]
if (thisRoute === false) return trie
let node = null
if (/^:|^\*/.test(thisRoute)) {
// if node is a name match, set name and append to ':' node
if (!has(trie.nodes, '$$')) {
node = { nodes: {} }
trie.nodes.$$ = node
} else {
node = trie.nodes.$$
}
if (thisRoute[0] === '*') {
trie.wildcard = true
}
trie.name = thisRoute.replace(/^:|^\*/, '')
} else {
// Literal segment: store it decoded + NFC-normalized so route
// definitions and incoming URLs compare in one canonical form.
const key = decodeSegment(thisRoute)
if (!has(trie.nodes, key)) {
node = { nodes: {} }
trie.nodes[key] = node
} else {
node = trie.nodes[key]
}
}
return createNode(index + 1, node)
}
return createNode(0, self.trie)
}
// match a route on the trie and return the node
match (route) {
equal(typeof route, 'string', 'route should be a string')
const routes = route.replace(/^\//, '').split('/')
const params = {}
function search (index, trie) {
// either there's no match, or we're done searching
if (trie === undefined) return undefined
if (routes[index] === undefined) return trie
// Segments arrive percent-encoded from the URL parser; decode each
// exactly once (create() stored literal keys in the same form).
const thisRoute = decodeSegment(routes[index])
if (has(trie.nodes, thisRoute)) {
// match regular routes first
return search(index + 1, trie.nodes[thisRoute])
} else if (trie.name) {
// match named routes
params[trie.name] = thisRoute
return search(index + 1, trie.nodes.$$)
} else if (trie.wildcard) {
// match wildcards; decode per segment so an encoded '/' can't
// change the segment structure mid-decode
params.wildcard = routes.slice(index).map(decodeSegment).join('/')
// return early, or else search may keep recursing through the wildcard
return trie.nodes.$$
} else {
// no matches found
return search(index + 1)
}
}
let node = search(0, this.trie)
if (!node) return undefined
node = Object.assign({}, node)
node.params = params
return node
}
// mount a trie onto a node at route
mount (route, trie) {
equal(typeof route, 'string', 'route should be a string')
equal(typeof trie, 'object', 'trie should be a object')
const split = route.replace(/^\//, '').split('/')
let node = null
if (split.length === 1) {
node = this.create(split[0])
} else {
node = this.create(split.join('/'))
}
Object.assign(node.nodes, trie.nodes)
if (trie.name) node.name = trie.name
// delegate properties from '/' to the new node
// '/' cannot be reached once mounted
if (node.nodes['']) {
Object.keys(node.nodes['']).forEach(function (key) {
if (key === 'nodes') return
node[key] = node.nodes[''][key]
})
Object.assign(node.nodes, node.nodes[''].nodes)
delete node.nodes[''].nodes
}
}
}
function wayfarer (dft) {
const _default = (dft || '').replace(/^\//, '')
const _trie = new Trie()
emit._trie = _trie
emit.on = on
emit.emit = emit
emit.match = match
emit._wayfarer = true
return emit
function on (route, cb) {
equal(typeof route, 'string')
equal(typeof cb, 'function')
route = route || '/'
if (cb._wayfarer && cb._trie) {
_trie.mount(route, cb._trie.trie)
} else {
const node = _trie.create(route)
node.cb = cb
node.route = route
}
return emit
}
function emit (route, ...args) {
const matched = match(route)
return matched.cb(matched.params, ...args)
}
function match (route) {
notEqual(route, undefined, "'route' must be defined")
const matched = _trie.match(route)
if (matched && matched.cb) return new Route(matched)
const dft = _trie.match(_default)
if (dft && dft.cb) return new Route(dft)
throw new Error("route '" + route + "' did not match")
}
function Route (matched) {
this.cb = matched.cb
this.route = matched.route
this.params = matched.params
}
}
// Reduce a location (path, full URL, or electron file:// URL) to a
// matchable path: pathname plus any hash segments rewritten to slashes,
// query dropped. Percent-decoding is NOT done here — the trie decodes per
// segment — so a malformed sequence can never throw during routing.
function pathname (routename) {
let url
try {
url = new URL(routename, 'http://localhost')
} catch (e) {
return routename
}
let path = url.pathname
// electron support: file:///path/to/index.html routes as '/'
if (url.protocol === 'file:') {
path = path.replace(/\/[^/]*\.html?$/, '') || '/'
}
const hash = url.hash.replace(/^#/, '/').replaceAll('#', '/')
return path + hash
}
export default class Nanorouter {
constructor (opts) {
opts = opts || {}
this.router = wayfarer(opts.default || '/404')
}
on (routename, listener) {
equal(typeof routename, 'string')
routename = routename.replace(/^[#/]/, '')
this.router.on(routename, listener)
}
emit (routename) {
equal(typeof routename, 'string')
routename = pathname(routename)
return this.router.emit(routename)
}
match (routename) {
equal(typeof routename, 'string')
routename = pathname(routename)
return this.router.match(routename)
}
}
export { Trie, wayfarer, ok }
+56
View File
@@ -0,0 +1,56 @@
// Ported from nanotiming 7.3.1 (MIT) — https://github.com/choojs/nanotiming
// Unified browser/Node implementation on the global `performance` object.
import { equal } from './assert.js'
const perf = typeof performance !== 'undefined' && typeof performance.mark === 'function'
? performance
: null
function checkDisabled () {
if (!perf) return true
if (typeof process !== 'undefined' && process.env && process.env.DISABLE_NANOTIMING) return true
if (typeof window !== 'undefined') {
try {
return window.localStorage.DISABLE_NANOTIMING === 'true'
} catch (e) {
return false
}
}
return false
}
nanotiming.disabled = checkDisabled()
export default function nanotiming (name) {
equal(typeof name, 'string', 'nanotiming: name should be type string')
if (nanotiming.disabled) return noop
const uuid = (perf.now() * 10000).toFixed() % Number.MAX_SAFE_INTEGER
const startName = 'start-' + uuid + '-' + name
perf.mark(startName)
function end (cb) {
const endName = 'end-' + uuid + '-' + name
perf.mark(endName)
let err = null
try {
const measureName = name + ' [' + uuid + ']'
perf.measure(measureName, startName, endName)
perf.clearMarks(startName)
perf.clearMarks(endName)
} catch (e) { err = e }
if (cb) cb(err, name)
}
end.uuid = uuid
return end
}
function noop (cb) {
if (cb) {
cb(new Error('nanotiming: performance API unavailable or disabled'))
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@uhhm/buuh",
"version": "8.0.0",
"description": "A 4kb framework for creating sturdy frontend applications",
"type": "module",
"exports": {
".": "./index.js",
"./timing": "./lib/timing.js"
},
"files": [
"index.js",
"lib"
],
"engines": {
"node": ">=24"
},
"dependencies": {
"@uhhm/buuh-html": "^8.0.0"
},
"repository": "https://project.uhhm.no/uhhm/buuh",
"keywords": [
"client",
"frontend",
"framework",
"minimal",
"composable",
"tiny"
],
"license": "MIT"
}
@@ -0,0 +1,59 @@
// End-to-end in happy-dom: the counter app running the real browser path —
// start(), store, emit, nanoraf-batched re-render, nanomorph in place.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let choo, html
before(async () => {
const win = new Window({ url: 'http://localhost/' })
globalThis.window = win
globalThis.document = win.document
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
;({ default: choo } = await import('@uhhm/buuh'))
;({ default: html } = await import('@uhhm/buuh-html/browser'))
})
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
test('a full app renders, emits, and morphs in the browser', async () => {
const app = choo()
app.use((state, emitter) => {
state.count = 0
emitter.on('increment', (n) => {
state.count += n
emitter.emit('render')
})
})
app.route('/', (state, emit) => {
return html`
<body>
<h1>count is ${state.count}</h1>
<button onclick=${() => emit('increment', 1)}>Increment</button>
</body>
`
})
const tree = app.start()
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 0')
// state event → render event → raf-batched morph of the same tree
app.emit('increment', 1)
await tick()
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 1', 'emit re-rendered in place')
// and through an actual DOM click on the interpolated handler
tree.querySelector('button').click()
await tick()
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 2', 'click handler emitted')
// raf batching: two synchronous emits, one render
app.emit('increment', 1)
app.emit('increment', 1)
await tick()
assert.strictEqual(tree.querySelector('h1').textContent, 'count is 4', 'batched emits coalesced')
})
+267
View File
@@ -0,0 +1,267 @@
// Ported from choo 7.1.0 test/node.js (MIT), tape → node:test.
// The hyperscript case now uses a plain object with outerHTML, which is the
// contract toString actually relies on.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@uhhm/buuh-html'
import raw from '@uhhm/buuh-html/raw'
import choo, { Choo } from '@uhhm/buuh'
test('should render on the server with @uhhm/buuh-html', () => {
const app = choo()
app.route('/', function (state, emit) {
const strong = '<strong>Hello filthy planet</strong>'
return html`
<p>${raw(strong)}</p>
`
})
const res = app.toString('/')
const exp = '<p><strong>Hello filthy planet</strong></p>'
assert.strictEqual(res.toString().trim(), exp, 'result was OK')
})
test('should render on the server with any view returning outerHTML', () => {
const app = choo()
app.route('/', function (state, emit) {
return { outerHTML: '<p><strong>Hello filthy planet</strong></p>', nodeName: 'P' }
})
const res = app.toString('/')
const exp = '<p><strong>Hello filthy planet</strong></p>'
assert.strictEqual(res.toString().trim(), exp, 'result was OK')
})
test('should expose a public API', () => {
const app = choo()
assert.strictEqual(typeof app.route, 'function', 'app.route prototype method exists')
assert.strictEqual(typeof app.toString, 'function', 'app.toString prototype method exists')
assert.strictEqual(typeof app.start, 'function', 'app.start prototype method exists')
assert.strictEqual(typeof app.mount, 'function', 'app.mount prototype method exists')
assert.strictEqual(typeof app.emitter, 'object', 'app.emitter prototype method exists')
assert.strictEqual(typeof app.emit, 'function', 'app.emit instance method exists')
assert.strictEqual(typeof app.router, 'object', 'app.router instance object exists')
assert.strictEqual(typeof app.state, 'object', 'app.state instance object exists')
})
test('the default export works with and without new, and instances are Choo', () => {
assert.ok(choo() instanceof Choo, 'choo() returns a Choo instance')
assert.ok(new Choo() instanceof Choo, 'new Choo() works')
})
test('should enable history and href by default', () => {
const app = choo()
assert.ok(app._historyEnabled, 'history enabled')
assert.ok(app._hrefEnabled, 'href enabled')
})
test('router should pass state and emit to view', () => {
let calls = 0
const app = choo()
app.route('/', function (state, emit) {
assert.strictEqual(typeof state, 'object', 'state is an object')
assert.strictEqual(typeof emit, 'function', 'emit is a function')
calls++
return html`<div></div>`
})
app.toString('/')
assert.strictEqual(calls, 1, 'view was rendered')
})
test('router should support a default route', () => {
let calls = 0
const app = choo()
app.route('*', function (state, emit) {
calls++
return html`<div></div>`
})
app.toString('/random')
assert.strictEqual(calls, 1, 'default route was rendered')
})
test('enabling hash routing should treat hashes as slashes', () => {
let calls = 0
const app = choo({ hash: true })
app.route('/account/security', function (state, emit) {
calls++
return html`<div></div>`
})
app.toString('/account#security')
assert.strictEqual(calls, 1, 'hash route was rendered')
})
test('router should ignore hashes by default', () => {
let calls = 0
const app = choo()
app.route('/account', function (state, emit) {
calls++
return html`<div></div>`
})
app.toString('/account#security')
assert.strictEqual(calls, 1, 'route was rendered')
})
test('cache should default to 100 instances', () => {
let pruned = 0
const app = choo()
app.route('/', function (state, emit) {
let i
for (i = 0; i <= 100; i++) state.cache(Component, i)
state.cache(Component, 0)
return html`<div></div>`
function Component (id) {
if (id < i) pruned++
}
})
app.toString('/')
assert.strictEqual(pruned, 1, 'oldest instance was pruned when exceeding 100')
})
test('cache option should override number of max instances', () => {
let pruned = 0
const app = choo({ cache: 1 })
app.route('/', function (state, emit) {
let instances = 0
state.cache(Component, instances)
state.cache(Component, instances)
state.cache(Component, 0)
return html`<div></div>`
function Component (id) {
if (id < instances) pruned++
instances++
}
})
app.toString('/')
assert.strictEqual(pruned, 1, 'oldest instance was pruned when exceeding 1')
})
test('cache option should override default LRU cache', () => {
let gets = 0
let sets = 0
const cache = {
get (id) { gets++ },
set (id, el) { sets++ }
}
const app = choo({ cache })
app.route('/', function (state, emit) {
state.cache(Component, 'foo')
return html`<div></div>`
})
app.toString('/')
assert.strictEqual(gets, 1, 'called get')
assert.strictEqual(sets, 1, 'called set')
function Component () {}
})
// built-in state
test('state should include events', () => {
let checked = false
const app = choo()
app.route('/', function (state, emit) {
assert.ok(Object.hasOwn(state, 'events'), 'state has events property')
assert.ok(Object.keys(state.events).length > 0, 'events object has keys')
checked = true
return html`<div></div>`
})
app.toString('/')
assert.ok(checked)
})
test('state should include location on render', () => {
let checked = false
const app = choo()
app.route('/:first/:second/*', function (state, emit) {
const params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
assert.strictEqual(state.href, '/foo/bar/file.txt', 'state has href')
assert.strictEqual(state.route, ':first/:second/*', 'state has route')
assert.ok(Object.hasOwn(state, 'params'), 'state has params')
assert.deepStrictEqual(state.params, params, 'params match')
assert.ok(Object.hasOwn(state, 'query'), 'state has query')
assert.deepStrictEqual(state.query, { bin: 'baz' }, 'query match')
checked = true
return html`<div></div>`
})
app.toString('/foo/bar/file.txt?bin=baz')
assert.ok(checked)
})
test('state should include location on store init', () => {
let checked = false
const app = choo()
app.use(store)
app.route('/:first/:second/*', function (state, emit) {
return html`<div></div>`
})
app.toString('/foo/bar/file.txt?bin=baz')
assert.ok(checked)
function store (state, emit) {
const params = { first: 'foo', second: 'bar', wildcard: 'file.txt' }
assert.strictEqual(state.href, '/foo/bar/file.txt', 'state has href')
assert.strictEqual(state.route, ':first/:second/*', 'state has route')
assert.ok(Object.hasOwn(state, 'params'), 'state has params')
assert.deepStrictEqual(state.params, params, 'params match')
assert.ok(Object.hasOwn(state, 'query'), 'state has query')
assert.deepStrictEqual(state.query, { bin: 'baz' }, 'query match')
checked = true
}
})
test('state should include cache', () => {
let constructed = 0
const app = choo()
app.route('/', function (state, emit) {
assert.strictEqual(typeof state.cache, 'function', 'state has cache method')
const cached = state.cache(Component, 'foo', 'arg')
assert.strictEqual(cached, state.cache(Component, 'foo'), 'consecutive calls return same instance')
return html`<div></div>`
})
app.toString('/')
assert.strictEqual(constructed, 1, 'component constructed once')
function Component (id, state, emit, arg) {
assert.strictEqual(id, 'foo', 'id was prefixed to constructor args')
assert.strictEqual(typeof state, 'object', 'state was prefixed to constructor args')
assert.strictEqual(typeof emit, 'function', 'emit was prefixed to constructor args')
assert.strictEqual(arg, 'arg', 'constructor args were forwarded')
constructed++
}
})
test('state should not mutate on toString', () => {
const app = choo()
app.use(store)
const routes = ['foo', 'bar']
const states = routes.map(function (route) {
const state = {}
app.route(`/${route}`, view)
app.toString(`/${route}`, state)
return state
})
for (let i = 0; i < routes.length; i++) {
assert.strictEqual(states[i].test, routes[i], 'store was used')
assert.strictEqual(states[i].title, routes[i], 'title was added to state')
}
function store (state, emitter) {
state.test = null
emitter.on('test', function (str) {
assert.strictEqual(state.test, null, 'state has been reset')
state.test = str
})
}
function view (state, emit) {
emit('test', state.route)
emit(state.events.DOMTITLECHANGE, state.route)
return html`<body>Hello ${state.route}</body>`
}
})
@@ -0,0 +1,93 @@
// The isomorphic handshake: server-render a view to a string, parse it
// into the document the way a browser would, then mount() the same app —
// the server DOM must be adopted (same element references), handlers must
// come alive, and a tampered server render must produce a console warning.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let choo, browserHtml, serverString
before(async () => {
// Server side first, before any window exists — exactly like production,
// and toString() enforces it.
;({ default: choo } = await import('@uhhm/buuh'))
const { default: serverHtml } = await import('@uhhm/buuh-html/server')
serverString = makeApp(serverHtml).toString('/')
const win = new Window({ url: 'http://localhost/' })
globalThis.window = win
globalThis.document = win.document
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
;({ default: browserHtml } = await import('@uhhm/buuh-html/browser'))
})
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
// One view definition, parameterized by renderer — mirrors how the import
// map serves browser.js to the browser and server.js to Node.
function makeApp (html) {
const app = choo()
app.use((state, emitter) => {
state.count = state.count || 0
emitter.on('increment', (n) => {
state.count += n
emitter.emit('render')
})
})
app.route('/', (state, emit) => html`
<div class="app">
<h1>count is ${state.count}</h1>
<button onclick=${() => emit('increment', 1)}>Increment</button>
</div>
`)
return app
}
test('mount adopts server-rendered DOM and brings it alive', async () => {
document.body.innerHTML = serverString
const serverEl = document.querySelector('.app')
const serverH1 = serverEl.querySelector('h1')
const warnings = []
const warn = console.warn
console.warn = (msg) => warnings.push(msg)
const app = makeApp(browserHtml)
app.mount('.app')
await tick()
console.warn = warn
assert.strictEqual(document.querySelector('.app'), serverEl, 'server root adopted, not replaced')
assert.strictEqual(serverEl.querySelector('h1'), serverH1, 'server child adopted, not replaced')
assert.deepStrictEqual(warnings, [], 'identical markup produced no mismatch warnings')
serverEl.querySelector('button').click()
await tick()
assert.strictEqual(serverH1.textContent, 'count is 1', 'adopted DOM is live')
})
test('a tampered server render produces one mismatch warning, client wins', async () => {
document.body.innerHTML = serverString.replace('count is 0', 'count is 999')
const warnings = []
const warn = console.warn
console.warn = (msg) => warnings.push(msg)
const app = makeApp(browserHtml)
app.mount('.app')
await tick()
console.warn = warn
assert.strictEqual(warnings.length, 1, 'exactly one warning')
assert.match(warnings[0], /server and client markup differ/)
assert.match(warnings[0], /count is 999/)
assert.strictEqual(
document.querySelector('h1').textContent,
'count is 0',
'client render won'
)
})
+82
View File
@@ -0,0 +1,82 @@
// Lazy routes in the browser (happy-dom): loading view while the module
// is in flight, morph to the real view on arrival, PENDING keeps the
// current tree during route changes, view cached after first load.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let choo, lazy, html
before(async () => {
const win = new Window({ url: 'http://localhost/' })
globalThis.window = win
globalThis.document = win.document
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
;({ default: choo, lazy } = await import('@uhhm/buuh'))
;({ default: html } = await import('@uhhm/buuh-html/browser'))
})
const tick = (ms = 30) => new Promise((resolve) => setTimeout(resolve, ms))
test('lazy route with a loading view: loading first, real view after', async () => {
let resolveLoader
const loaderDone = new Promise((resolve) => { resolveLoader = resolve })
const app = choo()
app.route('/', lazy(
() => loaderDone,
() => html`<div><p>loading…</p></div>`
))
const tree = app.start()
assert.strictEqual(tree.textContent, 'loading…', 'loading view rendered while in flight')
resolveLoader({ default: (state) => html`<div><p>arrived</p></div>` })
await tick()
assert.strictEqual(tree.textContent, 'arrived', 'real view morphed in over the loading view')
})
test('lazy route without a loading view: placeholder, then wholesale replace', async () => {
let resolveLoader
const loaderDone = new Promise((resolve) => { resolveLoader = resolve })
const app = choo()
app.route('/', lazy(() => loaderDone))
const tree = app.start()
assert.ok(tree.hasAttribute('data-choo-pending'), 'placeholder holds the spot')
document.body.appendChild(tree)
resolveLoader({ default: (state) => html`<main><h1>real</h1></main>` })
await tick()
assert.strictEqual(document.querySelector('main h1').textContent, 'real', 'real view replaced the placeholder in the DOM')
assert.strictEqual(document.querySelector('[data-choo-pending]'), null, 'placeholder gone')
document.querySelector('main').remove()
})
test('loaded lazy views render synchronously ever after', async () => {
const view = (state) => html`<div>cached ${state.count}</div>`
const wrapped = lazy(() => Promise.resolve({ default: view }))
const app = choo()
app.use((state, emitter) => {
state.count = 0
emitter.on('bump', () => { state.count++; emitter.emit('render') })
})
app.route('/', wrapped)
// no loading view: the placeholder must live in the DOM to be replaced
const holder = document.createElement('section')
document.body.appendChild(holder)
holder.appendChild(app.start())
await tick() // let the loader resolve + re-render
assert.strictEqual(holder.textContent, 'cached 0')
app.emit('bump')
await tick()
assert.strictEqual(holder.textContent, 'cached 1', 'subsequent renders are sync through the cache')
holder.remove()
})
+96
View File
@@ -0,0 +1,96 @@
// choo.toStream(): web-standard streaming SSR with prefetch and lazy routes.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@uhhm/buuh-html'
import choo, { lazy } from '@uhhm/buuh'
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms))
async function readAll (stream) {
const decoder = new TextDecoder()
const chunks = []
for await (const chunk of stream) chunks.push(decoder.decode(chunk, { stream: true }))
return chunks
}
test('toStream matches toString for a plain sync app', async () => {
const makeApp = () => {
const app = choo()
app.route('/', (state) => html`<div><h1>hi</h1></div>`)
return app
}
const chunks = await readAll(makeApp().toStream('/'))
assert.strictEqual(chunks.join(''), makeApp().toString('/'))
})
test('returns a web ReadableStream', () => {
const app = choo()
app.route('/', () => html`<div></div>`)
assert.ok(app.toStream('/') instanceof ReadableStream)
})
test('stores can defer rendering with state.prefetch promises', async () => {
const app = choo()
app.use((state) => {
state.user = null
state.prefetch.push(wait(10, null).then(() => { state.user = 'bendik' }))
})
app.route('/', (state) => html`<p>hello ${state.user}</p>`)
const chunks = await readAll(app.toStream('/'))
assert.strictEqual(chunks.join(''), '<p>hello bendik</p>', 'render waited for prefetch')
})
test('toString refuses prefetching stores with guidance', () => {
const app = choo()
app.use((state) => { state.prefetch.push(Promise.resolve()) })
app.route('/', () => html`<div></div>`)
assert.throws(() => app.toString('/'), /toStream/)
})
test('async template holes stream: shell first, slow content later', async () => {
const app = choo()
app.route('/', (state) => html`<body><h1>shell</h1>${wait(15, html`<section>slow</section>`)}</body>`)
const stream = app.toStream('/')
const reader = stream.getReader()
const decoder = new TextDecoder()
const started = performance.now()
const first = decoder.decode((await reader.read()).value)
const firstAt = performance.now() - started
assert.strictEqual(first, '<body><h1>shell</h1>', 'shell flushed immediately')
assert.ok(firstAt < 10, `shell arrived before the slow hole resolved (${firstAt.toFixed(1)}ms)`)
let rest = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
rest += decoder.decode(value, { stream: true })
}
assert.strictEqual(rest, '<section>slow</section></body>')
})
test('toStream awaits lazy route views; toString refuses them', async () => {
const makeApp = () => {
const app = choo()
app.route('/', lazy(() => wait(10, {
default: (state) => html`<main>lazy view</main>`
})))
return app
}
assert.throws(() => makeApp().toString('/'), /toStream/)
const chunks = await readAll(makeApp().toStream('/'))
assert.strictEqual(chunks.join(''), '<main>lazy view</main>')
})
test('stream errors propagate through the stream, not as sync throws', async () => {
const app = choo()
app.route('/', () => html`<div>${Promise.reject(new Error('boom'))}</div>`)
await assert.rejects(async () => readAll(app.toStream('/')), /boom/)
})
+114
View File
@@ -0,0 +1,114 @@
// URL normalization: the non-ASCII and malformed-percent cases that made
// v7 routing crash or mismatch. See lib/router.js header for the rules.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@uhhm/buuh-html'
import choo from '@uhhm/buuh'
function view (state) {
return html`<div></div>`
}
test('a literal % in the path does not crash routing', () => {
const app = choo()
let seen
app.route('/deals/:label', (state) => {
seen = state
return view(state)
})
app.toString('/deals/50%off')
assert.strictEqual(seen.params.label, '50%off', 'raw segment kept when not decodable')
assert.strictEqual(seen.href, '/deals/50%off', 'href kept raw when not decodable')
})
test('percent-encoded UTF-8 params decode exactly once', () => {
const app = choo()
let seen
app.route('/user/:name', (state) => {
seen = state
return view(state)
})
app.toString('/user/%F0%9F%9A%82')
assert.strictEqual(seen.params.name, '🚂', 'param decoded')
assert.strictEqual(seen.href, '/user/🚂', 'href decoded for humans')
})
test('double-encoded input is not double-decoded', () => {
const app = choo()
let seen
app.route('/user/:name', (state) => {
seen = state
return view(state)
})
// %2540 is '%40' encoded once; v7 double-decoded it all the way to '@'
app.toString('/user/%2540')
assert.strictEqual(seen.params.name, '%40', 'decoded exactly once')
})
test('non-ASCII literal routes match their encoded locations', () => {
const app = choo()
let calls = 0
app.route('/café', (state) => {
calls++
return view(state)
})
app.toString('/caf%C3%A9')
assert.strictEqual(calls, 1, 'encoded location matched unencoded route')
})
test('NFD input matches an NFC route definition', () => {
const app = choo()
let calls = 0
app.route('/café', (state) => { // composed é
calls++
return view(state)
})
app.toString('/café') // decomposed e + combining acute
assert.strictEqual(calls, 1, 'unicode-normalized before matching')
})
test('unencoded non-ASCII locations route fine', () => {
const app = choo()
let seen
app.route('/user/:name', (state) => {
seen = state
return view(state)
})
app.toString('/user/日本語')
assert.strictEqual(seen.params.name, '日本語')
})
test('wildcards decode per segment', () => {
const app = choo()
let seen
app.route('/files/*', (state) => {
seen = state
return view(state)
})
app.toString('/files/caf%C3%A9/na%C3%AFve.txt')
assert.strictEqual(seen.params.wildcard, 'café/naïve.txt')
})
test('hash routing survives multiple hashes', () => {
const app = choo({ hash: true })
let calls = 0
app.route('/docs/api/intro', (state) => {
calls++
return view(state)
})
app.toString('/docs#api#intro')
assert.strictEqual(calls, 1, 'every hash became a slash')
})
test('query strings decode + and percent-encoding via URLSearchParams', () => {
const app = choo()
let seen
app.route('/', (state) => {
seen = state
return view(state)
})
app.toString('/?q=caf%C3%A9+au+lait&tags=a&tags=b')
assert.deepStrictEqual(seen.query, { q: 'café au lait', tags: ['a', 'b'] })
})
+78
View File
@@ -0,0 +1,78 @@
// Console devtools for choo v8, carrying forward the essentials of
// choo-devtools 3.x (MIT): a window.choo handle with the live state, an
// event log, emit-from-the-console, and render timings via the
// PerformanceObserver watching nanotiming's measures. Usage:
//
// import devtools from '@uhhm/buuh-devtools'
// app.use(devtools())
//
// The store is a no-op on the server and (by default) stays quiet in the
// console; set localStorage.CHOO_DEVTOOLS_VERBOSE = 'true' to log every
// event as it happens.
const MAX_LOG = 1000
export default function devtools (opts) {
opts = opts || {}
const max = opts.max || MAX_LOG
return function devtoolsStore (state, emitter, app) {
if (typeof window === 'undefined') return
const log = []
let verbose = false
try {
verbose = window.localStorage.CHOO_DEVTOOLS_VERBOSE === 'true'
} catch (e) {}
emitter.on('*', function (name, ...data) {
const entry = { name: String(name), data, at: Date.now() }
log.push(entry)
if (log.length > max) log.shift()
if (verbose && name !== state.events.RENDER) {
console.debug('choo: %s', entry.name, ...data)
}
})
const timings = []
if (typeof PerformanceObserver === 'function') {
try {
const observer = new PerformanceObserver(function (list) {
for (const entry of list.getEntries()) {
if (!/\[\d+\]$/.test(entry.name)) continue // nanotiming measures only
timings.push({ name: entry.name.replace(/ \[\d+\]$/, ''), duration: entry.duration })
if (timings.length > max) timings.shift()
}
})
observer.observe({ entryTypes: ['measure'] })
} catch (e) {}
}
window.choo = {
state,
emit: emitter.emit.bind(emitter),
emitter,
app,
log,
timings,
// JSON snapshot of state (cache/functions excluded via toJSON)
copy () {
const json = JSON.stringify(state, null, 2)
if (navigator.clipboard) navigator.clipboard.writeText(json)
return json
},
help () {
console.log([
'window.choo — choo devtools',
' choo.state live app state',
' choo.emit(name, …data) fire an event',
' choo.log last ' + max + ' events',
' choo.timings nanotiming render measures',
' choo.copy() state as JSON (and to clipboard)',
" localStorage.CHOO_DEVTOOLS_VERBOSE = 'true' log every event"
].join('\n'))
return '☰'
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@uhhm/buuh-devtools",
"version": "8.0.0",
"description": "Console devtools for choo: window.choo, event log, perf timings",
"type": "module",
"exports": {
".": "./index.js"
},
"files": [
"index.js"
],
"engines": {
"node": ">=24"
},
"repository": "https://project.uhhm.no/uhhm/buuh",
"keywords": [
"choo",
"devtools",
"debug"
],
"license": "MIT"
}
+51
View File
@@ -0,0 +1,51 @@
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let choo, html, devtools
before(async () => {
const win = new Window({ url: 'http://localhost/' })
globalThis.window = win
globalThis.document = win.document
globalThis.requestAnimationFrame = win.requestAnimationFrame.bind(win)
;({ default: choo } = await import('@uhhm/buuh'))
;({ default: html } = await import('@uhhm/buuh-html/browser'))
;({ default: devtools } = await import('../index.js'))
})
test('devtools exposes window.choo with live state, log and emit', async () => {
const app = choo()
app.use(devtools())
app.use((state, emitter) => {
state.count = 0
emitter.on('bump', (n) => { state.count += n })
})
app.route('/', (state) => html`<div>${state.count}</div>`)
app.start()
assert.ok(window.choo, 'window.choo exists')
assert.strictEqual(window.choo.state.count, 0, 'live state exposed')
window.choo.emit('bump', 2)
assert.strictEqual(window.choo.state.count, 2, 'emit from the console handle works')
assert.ok(window.choo.log.some((e) => e.name === 'bump'), 'events land in the log')
assert.strictEqual(typeof window.choo.help, 'function')
const json = JSON.parse(window.choo.copy())
assert.strictEqual(json.count, 2, 'copy() serializes state')
assert.strictEqual(json.cache, null, 'state.cache not serialized')
})
test('devtools is a no-op on the server', async () => {
// fresh import in a windowless world is covered by the guard: calling
// the store with no window must not throw or touch globals
const store = devtools()
const hadWindow = globalThis.window
delete globalThis.window
try {
assert.doesNotThrow(() => store({}, { on () {} }, {}))
} finally {
globalThis.window = hadWindow
}
})
+265
View File
@@ -0,0 +1,265 @@
// The v8 browser renderer: a runtime-only tagged template.
//
// Each unique template literal is parsed once, keyed by its (frozen,
// per-call-site) strings array in a WeakMap: the static parts become a
// <template> element with markers where the holes are, plus a list of
// instructions (node paths + hole indices). Every render clones the
// template and fills the holes. No compile step, no HTML re-parsing on
// re-render — production speed is a property of the runtime, not of a
// build tool (this replaces nanohtml's browserify/babel transform).
//
// Interpolated values never pass through innerHTML: child values become
// text nodes or adopted DOM nodes, attribute values go through
// setAttribute. Only the author-written static strings are parsed as HTML.
//
// Known limits (documented, matching or narrowing nanohtml's):
// - dynamic tag names (html`<${tag}>`) are not supported
// - holes inside <script>/<style>/<textarea> raw text are not supported
// - an SVG fragment must include its <svg> root to get the right namespace
const BOOL_PROPS = [
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden',
'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline',
'readonly', 'required', 'reversed', 'selected'
]
const templateCache = new WeakMap()
const TEXT = 0
const TAG = 1
const DQ = 2
const SQ = 3
const COMMENT = 4
export default function html (strings, ...values) {
let entry = templateCache.get(strings)
if (!entry) {
entry = parseTemplate(strings)
templateCache.set(strings, entry)
}
return instantiate(entry, values)
}
export { html }
// Build the marker HTML for a template literal and parse it once.
function parseTemplate (strings) {
let src = ''
let state = TEXT
for (let i = 0; i < strings.length; i++) {
const s = strings[i]
for (let j = 0; j < s.length; j++) {
const c = s[j]
switch (state) {
case TEXT:
if (c === '<') {
if (s.startsWith('!--', j + 1)) {
state = COMMENT
j += 3
} else {
state = TAG
}
}
break
case TAG:
if (c === '>') state = TEXT
else if (c === '"') state = DQ
else if (c === "'") state = SQ
break
case DQ:
if (c === '"') state = TAG
break
case SQ:
if (c === "'") state = TAG
break
case COMMENT:
if (c === '-' && s.startsWith('->', j + 1)) {
state = TEXT
j += 2
}
break
}
}
src += s
if (i < strings.length - 1) {
if (state === TEXT) {
src += `<!--__h${i}__-->`
} else if (state === DQ || state === SQ) {
src += `__h${i}__`
} else if (state === TAG) {
if (/=\s*$/.test(s)) {
// unquoted attribute value: attr=${x}
src += `"__h${i}__"`
} else {
// attribute-name position: spread object, <div ${attrs}>
src += ` __h${i}__ `
}
}
// COMMENT position: holes inside comments are dropped
}
}
const instructions = []
// <template> parsing drops document-level tags (<body>, <head>, <html>),
// but choo views legitimately return html`<body>…`. Parse those as a
// full document instead and use the element itself as the clone root.
const docRoot = /^\s*<(html|head|body)[\s>]/i.exec(src)
if (docRoot) {
const parser = new window.DOMParser()
const doc = parser.parseFromString(src, 'text/html')
const tag = docRoot[1].toLowerCase()
const root = tag === 'html' ? doc.documentElement : tag === 'head' ? doc.head : doc.body
scanAttrs(root, [], instructions)
walk(root, [], instructions)
return { root, instructions }
}
const template = document.createElement('template')
template.innerHTML = src
walk(template.content, [], instructions)
return { root: template.content, instructions }
}
function walk (node, path, out) {
const kids = node.childNodes
for (let i = 0; i < kids.length; i++) {
const child = kids[i]
const childPath = path.concat(i)
if (child.nodeType === 8) { // comment
const m = /^__h(\d+)__$/.exec(child.data)
if (m) out.push({ type: 'child', path: childPath, hole: +m[1] })
} else if (child.nodeType === 1) { // element
scanAttrs(child, childPath, out)
walk(child, childPath, out)
}
}
}
function scanAttrs (el, path, out) {
for (const attr of [...el.attributes]) {
const nameMatch = /^__h(\d+)__$/.exec(attr.name)
if (nameMatch) {
el.removeAttribute(attr.name)
out.push({ type: 'spread', path, hole: +nameMatch[1] })
continue
}
if (/__h\d+__/.test(attr.value)) {
const sole = /^__h(\d+)__$/.exec(attr.value)
if (sole) {
// whole value is one hole: apply with type awareness at render
el.removeAttribute(attr.name)
out.push({ type: 'attr', path, name: attr.name, hole: +sole[1] })
} else {
// static text mixed with holes: string composition
const parts = attr.value.split(/__h(\d+)__/)
.map((p, idx) => idx % 2 ? +p : p)
.filter((p) => p !== '')
out.push({ type: 'parts', path, name: attr.name, parts })
}
}
}
}
function instantiate (entry, values) {
const frag = entry.root.cloneNode(true)
// Resolve every target before mutating: child replacements change
// sibling indices, so paths are only valid against the pristine clone.
const targets = entry.instructions.map((instr) => resolvePath(frag, instr.path))
entry.instructions.forEach((instr, i) => {
const node = targets[i]
if (instr.type === 'child') {
node.replaceWith(toNode(values[instr.hole]))
} else if (instr.type === 'attr') {
setAttr(node, instr.name, values[instr.hole])
} else if (instr.type === 'spread') {
const obj = values[instr.hole]
if (obj && typeof obj === 'object') {
for (const key of Object.keys(obj)) setAttr(node, key, obj[key])
}
} else if (instr.type === 'parts') {
const joined = instr.parts
.map((p) => typeof p === 'number' ? toAttrString(values[p]) : p)
.join('')
node.setAttribute(instr.name, joined)
}
})
// A document-level root (<body> etc.) is already an element
if (frag.nodeType === 1) return frag
// Single element root unwraps (choo views return one node);
// anything else stays a fragment.
let result = null
for (const child of frag.childNodes) {
if (child.nodeType === 3 && !child.data.trim()) continue
if (result === null) {
result = child
} else {
result = frag
break
}
}
return result === null ? frag : result
}
function resolvePath (root, path) {
let node = root
for (const index of path) node = node.childNodes[index]
return node
}
function toNode (value) {
if (value === null || value === undefined) {
return document.createTextNode('')
}
if (value.nodeType) return value
if (Array.isArray(value)) {
const frag = document.createDocumentFragment()
for (const item of value) frag.appendChild(toNode(item))
return frag
}
if (value.__encoded) {
// raw() output: author-vouched HTML, parsed on purpose
const t = document.createElement('template')
t.innerHTML = String(value)
return t.content
}
return document.createTextNode(String(value))
}
function setAttr (el, name, value) {
if (typeof value === 'function') {
// event handlers are set as properties so nanomorph can copy them
if (name.startsWith('on')) el[name] = value
return
}
if (BOOL_PROPS.includes(name)) {
el[name] = !!value
if (value) el.setAttribute(name, name)
else el.removeAttribute(name)
return
}
if (value === null || value === undefined || value === false) {
el.removeAttribute(name)
return
}
if (value === true) {
el.setAttribute(name, '')
return
}
el.setAttribute(name, String(value))
if (name === 'value' && 'value' in el) el.value = String(value)
}
function toAttrString (value) {
if (value === null || value === undefined) return ''
if (typeof value === 'function') return ''
return String(value)
}
+137
View File
@@ -0,0 +1,137 @@
// Hydration: adopt server-rendered DOM instead of replacing it.
//
// Because the server and browser renderers serialize identically, morphing
// the first client render onto the server DOM leaves matching nodes
// untouched — the server DOM is adopted in place (same element references,
// form state preserved) and only real differences mutate. What hydrate
// adds over a bare morph is detection: before morphing it walks both trees
// and reports the first place server and client markup disagree, which is
// exactly the class of bug (Date.now() in views, user-specific content,
// stale caches) that otherwise surfaces as a silent flash of changed
// content. The client render always wins.
import morph from './morph.js'
export default function hydrate (oldNode, newNode, opts) {
// The client tree can hold adjacent text nodes ("count is " + "0")
// where the parsed server HTML has one merged run ("count is 0") —
// identical serialization, different granularity. Fold the client tree
// to parser granularity so comparison and morph see matching shapes.
if (newNode.normalize) newNode.normalize()
const onMismatch = opts && opts.onMismatch
if (onMismatch) {
const diff = firstDifference(oldNode, newNode, [])
if (diff) onMismatch(diff)
}
return morph(oldNode, newNode)
}
export { hydrate }
// Depth-first parallel walk; returns { path, reason, server, client } for
// the first disagreement, or null when the trees agree.
function firstDifference (a, b, path) {
if (a.nodeType !== b.nodeType || a.nodeName !== b.nodeName) {
return {
path: pathString(path),
reason: 'node',
server: describe(a),
client: describe(b)
}
}
if (a.nodeType === 3 || a.nodeType === 8) { // text, comment
// whitespace-insensitive: the parser shuffles insignificant
// whitespace (e.g. text after </body> reparents into body), and
// morph reconciles it silently — only report meaningful text
if (a.nodeValue.trim() !== b.nodeValue.trim()) {
return {
path: pathString(path),
reason: 'text',
server: a.nodeValue,
client: b.nodeValue
}
}
return null
}
if (a.nodeType === 1) {
const attrDiff = attrDifference(a, b)
if (attrDiff) {
return {
path: pathString(path.concat(a.nodeName.toLowerCase())),
reason: 'attribute',
server: attrDiff.server,
client: attrDiff.client
}
}
const aKids = significant(a.childNodes)
const bKids = significant(b.childNodes)
if (aKids.length !== bKids.length) {
return {
path: pathString(path.concat(a.nodeName.toLowerCase())),
reason: 'children',
server: aKids.length + ' child node(s)',
client: bKids.length + ' child node(s)'
}
}
for (let i = 0; i < aKids.length; i++) {
const diff = firstDifference(aKids[i], bKids[i], path.concat(a.nodeName.toLowerCase()))
if (diff) return diff
}
}
return null
}
// child nodes that matter for comparison: everything except
// whitespace-only text nodes and <script> elements — server pages
// legitimately carry scripts (state serialization, analytics) that are
// already spent by hydration time and that client views never render
function significant (childNodes) {
const out = []
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i]
if (node.nodeType === 3 && !node.nodeValue.trim()) continue
if (node.nodeType === 1 && node.nodeName === 'SCRIPT') continue
out.push(node)
}
return out
}
function attrDifference (a, b) {
const aAttrs = a.attributes
const bAttrs = b.attributes
for (let i = 0; i < bAttrs.length; i++) {
const name = bAttrs[i].name
if (a.getAttribute(name) !== bAttrs[i].value) {
return {
server: name + '=' + JSON.stringify(a.getAttribute(name)),
client: name + '=' + JSON.stringify(bAttrs[i].value)
}
}
}
for (let i = 0; i < aAttrs.length; i++) {
const name = aAttrs[i].name
if (!b.hasAttribute(name)) {
return {
server: name + '=' + JSON.stringify(aAttrs[i].value),
client: name + ' (absent)'
}
}
}
return null
}
function describe (node) {
if (!node) return '(missing)'
if (node.nodeType === 3) return 'text ' + JSON.stringify(node.nodeValue)
if (node.nodeType === 8) return 'comment'
return '<' + node.nodeName.toLowerCase() + '>'
}
function pathString (path) {
return path.length ? path.join(' > ') : '(root)'
}
+322
View File
@@ -0,0 +1,322 @@
// Ported from nanomorph 5.4.3 (MIT) — https://github.com/choojs/nanomorph
// Morph one DOM tree into another. Single-module consolidation of
// index.js + lib/morph.js + lib/events.js.
/* eslint-disable eqeqeq */
function equal (a, b, message) {
if (a != b) throw new Error(message)
}
function notEqual (a, b, message) {
if (a == b) throw new Error(message)
}
/* eslint-enable eqeqeq */
const ELEMENT_NODE = 1
const TEXT_NODE = 3
const COMMENT_NODE = 8
const events = [
// attribute events (can be set with attributes)
'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover',
'onmousemove', 'onmouseout', 'onmouseenter', 'onmouseleave',
'ontouchcancel', 'ontouchend', 'ontouchmove', 'ontouchstart',
'ondragstart', 'ondrag', 'ondragenter', 'ondragleave', 'ondragover',
'ondrop', 'ondragend', 'onkeydown', 'onkeypress', 'onkeyup', 'onunload',
'onabort', 'onerror', 'onresize', 'onscroll', 'onselect', 'onchange',
'onsubmit', 'onreset', 'onfocus', 'onblur', 'oninput',
'onanimationend', 'onanimationiteration', 'onanimationstart',
// other common events
'oncontextmenu', 'onfocusin', 'onfocusout'
]
// Morph one tree into another tree
//
// no parent
// -> same: diff and walk children
// -> not same: replace and return
// old node doesn't exist
// -> insert new node
// new node doesn't exist
// -> delete old node
// nodes are not the same
// -> diff nodes and apply patch to old node
// nodes are the same
// -> walk all child nodes and append to old node
export default function nanomorph (oldTree, newTree, options) {
equal(typeof oldTree, 'object', 'nanomorph: oldTree should be an object')
equal(typeof newTree, 'object', 'nanomorph: newTree should be an object')
if (options && options.childrenOnly) {
updateChildren(newTree, oldTree)
return oldTree
}
notEqual(
newTree.nodeType,
11,
'nanomorph: newTree should have one root node (which is not a DocumentFragment)'
)
return walk(newTree, oldTree)
}
// Walk and morph a dom tree
function walk (newNode, oldNode) {
if (!oldNode) {
return newNode
} else if (!newNode) {
return null
} else if (newNode.isSameNode && newNode.isSameNode(oldNode)) {
return oldNode
} else if (newNode.tagName !== oldNode.tagName || getComponentId(newNode) !== getComponentId(oldNode)) {
return newNode
} else {
morph(newNode, oldNode)
updateChildren(newNode, oldNode)
return oldNode
}
}
function getComponentId (node) {
return node.dataset ? node.dataset.nanomorphComponentId : undefined
}
// Update the children of elements
function updateChildren (newNode, oldNode) {
let oldChild, newChild, morphed, oldMatch
// The offset is only ever increased, and used for [i - offset] in the loop
let offset = 0
for (let i = 0; ; i++) {
oldChild = oldNode.childNodes[i]
newChild = newNode.childNodes[i - offset]
// Both nodes are empty, do nothing
if (!oldChild && !newChild) {
break
// There is no new child, remove old
} else if (!newChild) {
oldNode.removeChild(oldChild)
i--
// There is no old child, add new
} else if (!oldChild) {
oldNode.appendChild(newChild)
offset++
// Both nodes are the same, morph
} else if (same(newChild, oldChild)) {
morphed = walk(newChild, oldChild)
if (morphed !== oldChild) {
oldNode.replaceChild(morphed, oldChild)
offset++
}
// Both nodes do not share an ID or a placeholder, try reorder
} else {
oldMatch = null
// Try and find a similar node somewhere in the tree
for (let j = i; j < oldNode.childNodes.length; j++) {
if (same(oldNode.childNodes[j], newChild)) {
oldMatch = oldNode.childNodes[j]
break
}
}
// If there was a node with the same ID or placeholder in the old list
if (oldMatch) {
morphed = walk(newChild, oldMatch)
if (morphed !== oldMatch) offset++
oldNode.insertBefore(morphed, oldChild)
// It's safe to morph two nodes in-place if neither has an ID
} else if (!newChild.id && !oldChild.id) {
morphed = walk(newChild, oldChild)
if (morphed !== oldChild) {
oldNode.replaceChild(morphed, oldChild)
offset++
}
// Insert the node at the index if we couldn't morph or find a matching node
} else {
oldNode.insertBefore(newChild, oldChild)
offset++
}
}
}
}
function same (a, b) {
if (a.id) return a.id === b.id
if (a.isSameNode) return a.isSameNode(b)
if (a.tagName !== b.tagName) return false
if (a.type === TEXT_NODE) return a.nodeValue === b.nodeValue
return false
}
// diff elements and apply the resulting patch to the old node
function morph (newNode, oldNode) {
const nodeType = newNode.nodeType
const nodeName = newNode.nodeName
if (nodeType === ELEMENT_NODE) {
copyAttrs(newNode, oldNode)
}
if (nodeType === TEXT_NODE || nodeType === COMMENT_NODE) {
if (oldNode.nodeValue !== newNode.nodeValue) {
oldNode.nodeValue = newNode.nodeValue
}
}
// Some DOM nodes are weird
// https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
if (nodeName === 'INPUT') updateInput(newNode, oldNode)
else if (nodeName === 'OPTION') updateOption(newNode, oldNode)
else if (nodeName === 'TEXTAREA') updateTextarea(newNode, oldNode)
copyEvents(newNode, oldNode)
}
function copyAttrs (newNode, oldNode) {
const oldAttrs = oldNode.attributes
const newAttrs = newNode.attributes
let attrNamespaceURI = null
let attrValue = null
let fromValue = null
let attrName = null
let attr = null
for (let i = newAttrs.length - 1; i >= 0; --i) {
attr = newAttrs[i]
attrName = attr.name
attrNamespaceURI = attr.namespaceURI
attrValue = attr.value
if (attrNamespaceURI) {
attrName = attr.localName || attrName
fromValue = oldNode.getAttributeNS(attrNamespaceURI, attrName)
if (fromValue !== attrValue) {
oldNode.setAttributeNS(attrNamespaceURI, attrName, attrValue)
}
} else {
if (!oldNode.hasAttribute(attrName)) {
oldNode.setAttribute(attrName, attrValue)
} else {
fromValue = oldNode.getAttribute(attrName)
if (fromValue !== attrValue) {
// apparently values are always cast to strings, ah well
if (attrValue === 'null' || attrValue === 'undefined') {
oldNode.removeAttribute(attrName)
} else {
oldNode.setAttribute(attrName, attrValue)
}
}
}
}
}
// Remove any extra attributes found on the original DOM element that
// weren't found on the target element.
for (let j = oldAttrs.length - 1; j >= 0; --j) {
attr = oldAttrs[j]
if (attr.specified !== false) {
attrName = attr.name
attrNamespaceURI = attr.namespaceURI
if (attrNamespaceURI) {
attrName = attr.localName || attrName
if (!newNode.hasAttributeNS(attrNamespaceURI, attrName)) {
oldNode.removeAttributeNS(attrNamespaceURI, attrName)
}
} else {
if (!newNode.hasAttributeNS(null, attrName)) {
oldNode.removeAttribute(attrName)
}
}
}
}
}
function copyEvents (newNode, oldNode) {
for (let i = 0; i < events.length; i++) {
const ev = events[i]
if (newNode[ev]) { // if new element has a whitelisted attribute
oldNode[ev] = newNode[ev] // update existing element
} else if (oldNode[ev]) { // if existing element has it and new one doesnt
oldNode[ev] = undefined // remove it from existing element
}
}
}
function updateOption (newNode, oldNode) {
updateAttribute(newNode, oldNode, 'selected')
}
// The "value" attribute is special for the <input> element since it sets the
// initial value. Changing the "value" attribute without changing the "value"
// property will have no effect since it is only used to the set the initial
// value. Similar for the "checked" attribute, and "disabled".
function updateInput (newNode, oldNode) {
const newValue = newNode.value
const oldValue = oldNode.value
updateAttribute(newNode, oldNode, 'checked')
updateAttribute(newNode, oldNode, 'disabled')
// The "indeterminate" property can not be set using an HTML attribute.
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox
if (newNode.indeterminate !== oldNode.indeterminate) {
oldNode.indeterminate = newNode.indeterminate
}
// Persist file value since file inputs can't be changed programatically
if (oldNode.type === 'file') return
if (newValue !== oldValue) {
oldNode.setAttribute('value', newValue)
oldNode.value = newValue
}
if (newValue === 'null') {
oldNode.value = ''
oldNode.removeAttribute('value')
}
if (!newNode.hasAttributeNS(null, 'value')) {
oldNode.removeAttribute('value')
} else if (oldNode.type === 'range') {
// this is so elements like slider move their UI thingy
oldNode.value = newValue
}
}
function updateTextarea (newNode, oldNode) {
const newValue = newNode.value
if (newValue !== oldNode.value) {
oldNode.value = newValue
}
if (oldNode.firstChild && oldNode.firstChild.nodeValue !== newValue) {
// Needed for IE. Apparently IE sets the placeholder as the
// node value and vise versa. This ignores an empty update.
if (newValue === '' && oldNode.firstChild.nodeValue === oldNode.placeholder) {
return
}
oldNode.firstChild.nodeValue = newValue
}
}
function updateAttribute (newNode, oldNode, name) {
if (newNode[name] !== oldNode[name]) {
oldNode[name] = newNode[name]
if (newNode[name]) {
oldNode.setAttribute(name, '')
} else {
oldNode.removeAttribute(name)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@uhhm/buuh-html",
"version": "8.0.0",
"description": "HTML template literals that render to DOM in the browser and strings on the server",
"type": "module",
"exports": {
".": {
"browser": "./browser.js",
"default": "./server.js"
},
"./browser": "./browser.js",
"./server": "./server.js",
"./hydrate": "./hydrate.js",
"./raw": "./raw.js",
"./morph": "./morph.js"
},
"files": [
"browser.js",
"server.js",
"hydrate.js",
"raw.js",
"morph.js"
],
"engines": {
"node": ">=24"
},
"repository": "https://project.uhhm.no/uhhm/buuh",
"keywords": [
"html",
"template",
"tagged-template",
"server-side-rendering"
],
"license": "MIT"
}
+11
View File
@@ -0,0 +1,11 @@
// Ported from nanohtml 1.10.0 lib/raw-server.js (MIT)
// Mark a string as pre-encoded so the html tag won't escape it.
export default function raw (tag) {
// eslint-disable-next-line no-new-wrappers
const wrapper = new String(tag)
wrapper.__encoded = true
return wrapper
}
export { raw }
+255
View File
@@ -0,0 +1,255 @@
// Ported from nanohtml 1.10.0 lib/server.js (MIT) — https://github.com/choojs/nanohtml
// Server-side tagged template. The browserify/babel transform branches of
// the original are gone for good — this is a pure runtime tag.
//
// v8 shape: the tag builds a parts list — static/escaped string runs
// interleaved with unresolved async holes (promises or async iterables in
// child position). A fully-sync template collapses to one string and
// toString() behaves exactly like v7. Async holes make the result
// streamable: iterate it with `for await` (each chunk is a string) — that
// is what choo's toStream() does. toString() on async content throws.
// Async values are only allowed in child position; attribute values must
// be synchronous.
const BOOL_PROPS = [
'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default',
'defaultchecked', 'defer', 'disabled', 'formnovalidate', 'hidden',
'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'open', 'playsinline',
'readonly', 'required', 'reversed', 'selected'
]
const boolPropRx = new RegExp('([^-a-z](' + BOOL_PROPS.join('|') + '))=["\']?$', 'i')
const handlerRx = /[^-a-z](on[a-z]+)=$/i
const query = /(?:="|&)[^"]*=$/
// minimal parser state, only to distinguish child position from tag position
const TEXT = 0
const TAG = 1
const DQ = 2
const SQ = 3
const COMMENT = 4
function scan (s, state) {
for (let j = 0; j < s.length; j++) {
const c = s[j]
switch (state) {
case TEXT:
if (c === '<') {
if (s.startsWith('!--', j + 1)) { state = COMMENT; j += 3 } else state = TAG
}
break
case TAG:
if (c === '>') state = TEXT
else if (c === '"') state = DQ
else if (c === "'") state = SQ
break
case DQ:
if (c === '"') state = TAG
break
case SQ:
if (c === "'") state = TAG
break
case COMMENT:
if (c === '-' && s.startsWith('->', j + 1)) { state = TEXT; j += 2 }
break
}
}
return state
}
function isThenable (value) {
return value && typeof value.then === 'function'
}
function isAsyncIterable (value) {
return value && typeof value[Symbol.asyncIterator] === 'function'
}
export class HtmlChunks {
constructor (parts) {
this.parts = parts // strings and { async: value } holes
this.__encoded = true
}
get async () {
return this.parts.some((p) => typeof p !== 'string')
}
toString () {
let out = ''
for (const part of this.parts) {
if (typeof part !== 'string') {
throw new Error(
'@uhhm/buuh-html: this template has async content and cannot render synchronously — stream it (choo: use toStream() instead of toString())'
)
}
out += part
}
return out
}
async * [Symbol.asyncIterator] () {
for (const part of this.parts) {
if (typeof part === 'string') {
yield part
} else {
yield * resolveAsync(part.async)
}
}
}
}
// Resolve an async hole to string chunks, applying child-value semantics
// to whatever it produces.
async function * resolveAsync (value) {
if (isThenable(value)) {
yield * streamChild(await value)
} else {
for await (const item of value) {
yield * streamChild(item)
}
}
}
async function * streamChild (value) {
if (value === null || value === undefined) return
// HtmlChunks is itself async-iterable: its chunks are final HTML and
// must never re-enter escaping, so check it before the generic paths
if (value instanceof HtmlChunks) {
yield * value[Symbol.asyncIterator]()
return
}
if (isThenable(value) || isAsyncIterable(value)) {
yield * resolveAsync(value)
return
}
if (Array.isArray(value)) {
for (const item of value) yield * streamChild(item)
return
}
const str = handleChild(value)
if (str !== '') yield str
}
export default function html (pieces, ...values) {
const parts = []
let acc = ''
let boolMatch
let state = TEXT
for (let i = 0; i < pieces.length; i++) {
const piece = pieces[i]
state = scan(piece, state)
if (i === pieces.length - 1) {
acc += piece
break
}
const value = values[i]
if (state === TEXT) {
// child position: this is where async content may live
// (HtmlChunks is itself async-iterable, so check it first)
acc += piece
if (value instanceof HtmlChunks) {
// splice nested parts so inner async holes stream through
for (const part of value.parts) {
if (typeof part === 'string') acc += part
else { parts.push(acc, part); acc = '' }
}
} else if (isThenable(value) || isAsyncIterable(value)) {
parts.push(acc, { async: value })
acc = ''
} else {
acc += handleChild(value)
}
continue
}
// tag position: attributes are synchronous, always
if (isThenable(value) || isAsyncIterable(value)) {
throw new Error('@uhhm/buuh-html: async values are only allowed in child position, not in attributes')
}
// Event handlers are behavior, not markup: `onclick=${fn}` renders
// nothing at all, matching the browser renderer (which sets the
// handler as a property). v7 serialized a useless onclick="".
const handlerMatch = handlerRx.exec(piece)
if (handlerMatch && typeof value === 'function') {
acc += piece.slice(0, handlerMatch.index + 1).replace(/\s+$/, ' ')
continue
}
if ((boolMatch = boolPropRx.exec(piece))) {
acc += piece.slice(0, boolMatch.index)
if (value) {
acc += boolMatch[1] + '="' + boolMatch[2] + '"'
}
continue
}
const handled = handleValue(value)
if (piece[piece.length - 1] === '=' && !query.test(piece)) {
acc += piece + '"' + handled + '"'
} else {
acc += piece + handled
}
}
parts.push(acc)
return new HtmlChunks(parts)
}
// child-position (text) value → string
function handleChild (value) {
if (Array.isArray(value)) return value.map(handleChild).join('')
if (typeof value === 'function') return ''
if (value === null || value === undefined) return ''
if (value.__encoded) return value.toString()
if (typeof value === 'object' && typeof value.outerHTML === 'string') return value.outerHTML
return escape(value.toString())
}
// tag-position (attribute) value → string; keeps v7 semantics including
// object spread and arrays
function handleValue (value) {
// Handle each item in array as potential unescaped value
if (Array.isArray(value)) return value.map(handleValue).join('')
if (typeof value === 'function') return ''
if (value === null || value === undefined) return ''
if (value.__encoded) return value.toString()
if (typeof value === 'object') {
if (typeof value.outerHTML === 'string') return value.outerHTML
return Object.keys(value).reduce(function (str, key) {
// handlers in spread objects are behavior too — never serialized
if (typeof value[key] === 'function') return str
if (str.length > 0) str += ' '
if (BOOL_PROPS.indexOf(key) !== -1) {
if (value[key]) {
return str + key + '="' + key + '"'
}
return str
}
const handled = handleValue(value[key])
return str + key + '="' + handled + '"'
}, '')
}
return escape(value.toString())
}
function escape (str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export { html }
+148
View File
@@ -0,0 +1,148 @@
// Browser renderer, exercised in happy-dom. Globals are installed before
// the dynamic import because browser.js touches `document` at parse time.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let html, raw, morph
before(async () => {
const win = new Window()
globalThis.window = win
globalThis.document = win.document
;({ default: html } = await import('../browser.js'))
;({ default: raw } = await import('../raw.js'))
;({ default: morph } = await import('../morph.js'))
})
test('renders an element with text', () => {
const el = html`<p>hello</p>`
assert.strictEqual(el.tagName, 'P')
assert.strictEqual(el.textContent, 'hello')
})
test('interpolated strings become text, never markup', () => {
const el = html`<p>${'<script>alert(1)</script>'}</p>`
assert.strictEqual(el.querySelector('script'), null)
assert.strictEqual(el.textContent, '<script>alert(1)</script>')
})
test('the same call site reuses its parsed template', () => {
const view = (x) => html`<span>${x}</span>`
const a = view('a')
const b = view('b')
assert.notStrictEqual(a, b, 'each render is a fresh node')
assert.strictEqual(a.textContent, 'a')
assert.strictEqual(b.textContent, 'b')
})
test('nested templates embed as nodes', () => {
const inner = html`<em>hi</em>`
const el = html`<p>${inner}</p>`
assert.strictEqual(el.querySelector('em'), inner)
})
test('arrays render as siblings', () => {
const items = ['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)
const el = html`<ul>${items}</ul>`
assert.strictEqual(el.querySelectorAll('li').length, 3)
assert.strictEqual(el.textContent, 'abc')
})
test('null and undefined children render as nothing', () => {
const el = html`<p>${null}${undefined}ok</p>`
assert.strictEqual(el.textContent, 'ok')
})
test('numbers and booleans stringify', () => {
const el = html`<p>${42} ${false}</p>`
assert.strictEqual(el.textContent, '42 false')
})
test('raw() parses vouched HTML', () => {
const el = html`<p>${raw('<em>hi</em>')}</p>`
assert.ok(el.querySelector('em'))
})
test('unquoted attribute holes', () => {
const el = html`<div class=${'a b'}></div>`
assert.strictEqual(el.getAttribute('class'), 'a b')
})
test('quoted attribute holes compose with static text', () => {
const el = html`<div class="btn ${'primary'} lg"></div>`
assert.strictEqual(el.getAttribute('class'), 'btn primary lg')
})
test('boolean attributes toggle', () => {
const on = html`<input disabled=${true} />`
const off = html`<input disabled=${false} />`
assert.ok(on.hasAttribute('disabled'))
assert.strictEqual(on.disabled, true)
assert.ok(!off.hasAttribute('disabled'))
})
test('event handlers attach as properties', () => {
let clicks = 0
const el = html`<button onclick=${() => clicks++}>go</button>`
assert.strictEqual(el.getAttribute('onclick'), null, 'no handler serialized to markup')
el.click()
assert.strictEqual(clicks, 1)
})
test('spread objects set attributes and handlers', () => {
let clicks = 0
const el = html`<div ${{ class: 'x', hidden: true, onclick: () => clicks++ }}></div>`
assert.strictEqual(el.getAttribute('class'), 'x')
assert.ok(el.hasAttribute('hidden'))
el.click()
assert.strictEqual(clicks, 1)
})
test('value attribute also sets the property', () => {
const el = html`<input value=${'typed'} />`
assert.strictEqual(el.value, 'typed')
})
test('multiple roots return a fragment', () => {
const frag = html`<li>a</li><li>b</li>`
assert.strictEqual(frag.nodeType, 11)
assert.strictEqual(frag.childNodes.length, 2)
})
test('leading/trailing whitespace still unwraps a single root', () => {
const el = html`
<p>hi</p>
`
assert.strictEqual(el.tagName, 'P')
})
test('document-level roots survive parsing, attributes included', () => {
// <template> silently drops <body>/<head>/<html>; we must not
const el = html`<body class=${'app'}><h1>${'hi'}</h1></body>`
assert.strictEqual(el.tagName, 'BODY')
assert.strictEqual(el.getAttribute('class'), 'app')
assert.strictEqual(el.querySelector('h1').textContent, 'hi')
})
test('re-render + morph updates in place and keeps handlers', () => {
let clicks = 0
const view = (n) => html`<div><button onclick=${() => clicks++}>count ${n}</button></div>`
const tree = view(0)
document.body.appendChild(tree)
morph(tree, view(1))
assert.strictEqual(tree.textContent, 'count 1', 'text updated')
tree.querySelector('button').click()
assert.strictEqual(clicks, 1, 'handler from new tree is live')
document.body.removeChild(tree)
})
test('server and browser renderers agree on simple markup', async () => {
// spot check: identical template, identical serialization
const { default: serverHtml } = await import('../server.js')
const browserEl = html`<p class=${'x'}>hi ${'there'}</p>`
const serverStr = serverHtml`<p class=${'x'}>hi ${'there'}</p>`
assert.strictEqual(browserEl.outerHTML, serverStr.toString())
})
+87
View File
@@ -0,0 +1,87 @@
// Hydration: adoption semantics and mismatch detection.
import { test, before } from 'node:test'
import assert from 'node:assert'
import { Window } from 'happy-dom'
let html, hydrate
before(async () => {
const win = new Window()
globalThis.window = win
globalThis.document = win.document
;({ default: html } = await import('../browser.js'))
;({ default: hydrate } = await import('../hydrate.js'))
})
test('agreeing trees hydrate without a mismatch report', () => {
const a = html`<div class="x"><h1>hi</h1><p>body</p></div>`
const b = html`<div class="x"><h1>hi</h1><p>body</p></div>`
let reported = null
hydrate(a, b, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported, null)
})
test('adoption: matching child nodes keep their identity through hydrate', () => {
const server = html`<div><h1>hi</h1><input value="typed by user" /></div>`
const serverH1 = server.querySelector('h1')
const serverInput = server.querySelector('input')
const client = html`<div><h1>hi</h1><input value="typed by user" /></div>`
hydrate(server, client)
assert.strictEqual(server.querySelector('h1'), serverH1, 'h1 adopted in place')
assert.strictEqual(server.querySelector('input'), serverInput, 'input adopted in place')
})
test('text mismatches are reported with a path, client wins', () => {
const server = html`<div><h1>count is 0</h1></div>`
const client = html`<div><h1>count is 7</h1></div>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported.reason, 'text')
assert.match(reported.path, /div > h1/)
assert.strictEqual(reported.server, 'count is 0')
assert.strictEqual(reported.client, 'count is 7')
assert.strictEqual(server.textContent, 'count is 7', 'client render won')
})
test('attribute mismatches are reported', () => {
const server = html`<div><a href="/old">go</a></div>`
const client = html`<div><a href="/new">go</a></div>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported.reason, 'attribute')
assert.match(reported.server, /\/old/)
assert.match(reported.client, /\/new/)
})
test('child count mismatches are reported', () => {
const server = html`<ul><li>a</li></ul>`
const client = html`<ul><li>a</li><li>b</li></ul>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported.reason, 'children')
})
test('server-only <script> elements are not reported as mismatches', () => {
// server pages carry state/analytics scripts the client never renders;
// they are spent by hydration time (morph may drop them — harmless)
const wrap = document.createElement('div')
wrap.innerHTML = '<div><h1>hi</h1><script>window.x=1</script></div>'
const server = wrap.firstChild
const client = html`<div><h1>hi</h1></div>`
let reported = null
hydrate(server, client, { onMismatch: (d) => { reported = d } })
assert.strictEqual(reported, null)
})
test('mismatch detection is optional and hydrate still morphs without it', () => {
const server = html`<p>old</p>`
const client = html`<p>new</p>`
hydrate(server, client)
assert.strictEqual(server.textContent, 'new')
})
+61
View File
@@ -0,0 +1,61 @@
// Server renderer behavior, matching nanohtml 1.x server semantics.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@uhhm/buuh-html'
import raw from '@uhhm/buuh-html/raw'
test('renders a template to a string', () => {
const res = html`<p>hello</p>`
assert.strictEqual(res.toString(), '<p>hello</p>')
})
test('escapes interpolated text', () => {
const res = html`<p>${'<script>alert(1)</script>'}</p>`
assert.strictEqual(res.toString(), '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>')
})
test('raw() bypasses escaping', () => {
const res = html`<p>${raw('<em>hi</em>')}</p>`
assert.strictEqual(res.toString(), '<p><em>hi</em></p>')
})
test('nested templates are not double-encoded', () => {
const inner = html`<em>&</em>`
const res = html`<p>${inner}</p>`
assert.strictEqual(res.toString(), '<p><em>&</em></p>')
})
test('arrays of children are joined', () => {
const items = ['a', 'b'].map((x) => html`<li>${x}</li>`)
const res = html`<ul>${items}</ul>`
assert.strictEqual(res.toString(), '<ul><li>a</li><li>b</li></ul>')
})
test('unquoted attribute interpolation is quoted', () => {
const res = html`<div class=${'a b'}></div>`
assert.strictEqual(res.toString(), '<div class="a b"></div>')
})
test('boolean attributes render when truthy and drop when falsy', () => {
const on = html`<input disabled=${true} />`
const off = html`<input disabled=${false} />`
assert.strictEqual(on.toString(), '<input disabled="disabled" />')
assert.strictEqual(off.toString(), '<input />'.replace(' ', ' '))
})
test('event handler functions leave no trace in markup', () => {
const res = html`<button onclick=${() => {}}>go</button>`
assert.strictEqual(res.toString(), '<button >go</button>')
})
test('handler-shaped attributes with non-function values still render', () => {
const res = html`<a onclick=${'confirm()'}>x</a>`
assert.strictEqual(res.toString(), '<a onclick="confirm()">x</a>')
})
test('spread-style object interpolation renders attributes', () => {
const res = html`<div ${{ class: 'x', hidden: true }}></div>`
assert.strictEqual(res.toString(), '<div class="x" hidden="hidden"></div>')
})
+78
View File
@@ -0,0 +1,78 @@
// Async holes and streaming in the server tag.
import { test } from 'node:test'
import assert from 'node:assert'
import html from '@uhhm/buuh-html'
import raw from '@uhhm/buuh-html/raw'
async function collect (chunks) {
const out = []
for await (const chunk of chunks) out.push(chunk)
return out
}
const wait = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms))
test('sync templates stream as a single chunk equal to toString()', async () => {
const res = html`<p>hello ${'world'}</p>`
const chunks = await collect(res)
assert.deepStrictEqual(chunks, ['<p>hello world</p>'])
assert.strictEqual(chunks.join(''), res.toString())
})
test('a promise child splits the stream at the hole', async () => {
const res = html`<main><h1>shell</h1>${wait(10, 'late')}<footer>end</footer></main>`
const chunks = await collect(res)
assert.strictEqual(chunks[0], '<main><h1>shell</h1>', 'everything before the hole flushes first')
assert.strictEqual(chunks.join(''), '<main><h1>shell</h1>late<footer>end</footer></main>')
})
test('toString() on async content throws with streaming guidance', () => {
const res = html`<p>${Promise.resolve('x')}</p>`
assert.throws(() => res.toString(), /toStream/)
})
test('resolved promises can carry templates, arrays, and raw', async () => {
const res = html`<ul>${wait(5, [html`<li>a</li>`, html`<li>${'<b>'}</li>`, raw('<li>raw</li>')])}</ul>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<ul><li>a</li><li>&lt;b&gt;</li><li>raw</li></ul>')
})
test('nested templates with async holes stream through their parents', async () => {
const inner = html`<section>${wait(5, 'inner-late')}</section>`
const res = html`<div><p>early</p>${inner}</div>`
const chunks = await collect(res)
assert.strictEqual(chunks[0], '<div><p>early</p><section>', 'parent flushes up to the nested hole')
assert.strictEqual(chunks.join(''), '<div><p>early</p><section>inner-late</section></div>')
})
test('async iterable children stream chunk by chunk', async () => {
async function * rows () {
for (let i = 0; i < 3; i++) yield html`<li>${i}</li>`
}
const res = html`<ul>${rows()}</ul>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<ul><li>0</li><li>1</li><li>2</li></ul>')
assert.ok(chunks.length >= 4, 'each yielded row is its own chunk')
})
test('multiple async holes resolve in document order', async () => {
// the second promise resolves first; output order must follow the document
const res = html`<div>${wait(20, 'A')}|${wait(5, 'B')}</div>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<div>A|B</div>')
})
test('async values in attribute position throw immediately', () => {
assert.throws(
() => html`<div class=${Promise.resolve('x')}></div>`,
/child position/
)
})
test('resolved async strings are escaped like any child', async () => {
const res = html`<p>${wait(5, '<script>')}</p>`
const chunks = await collect(res)
assert.strictEqual(chunks.join(''), '<p>&lt;script&gt;</p>')
})
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env node
// choo-migrate [--dry] <files or directories…>
// Rewrites choo v7 sources toward v8: CJS → ESM for simple top-level
// patterns, package specifiers remapped, and a per-file report of what
// needs human hands. --dry prints the report without writing.
import { readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { join, extname } from 'node:path'
import transform from './lib/transform.js'
const args = process.argv.slice(2)
const dry = args.includes('--dry')
const targets = args.filter((a) => a !== '--dry')
if (targets.length === 0) {
console.log('usage: choo-migrate [--dry] <files or directories…>')
process.exit(1)
}
const SKIP = new Set(['node_modules', '.git', 'dist', 'coverage'])
async function * walk (path) {
const info = await stat(path)
if (info.isFile()) {
if (extname(path) === '.js' || extname(path) === '.mjs') yield path
return
}
for (const entry of await readdir(path)) {
if (SKIP.has(entry)) continue
yield * walk(join(path, entry))
}
}
let changedCount = 0
for (const target of targets) {
for await (const file of walk(target)) {
const source = await readFile(file, 'utf8')
const { code, changed, notes } = transform(source)
if (!changed && notes.length === 0) continue
console.log(`\n${file}${changed ? (dry ? ' (would change)' : ' (rewritten)') : ''}`)
for (const note of notes) console.log(`${note}`)
if (changed && !dry) {
await writeFile(file, code)
changedCount++
}
}
}
console.log(`\n${dry ? 'dry run — no files written' : changedCount + ' file(s) rewritten'}. Remember: package.json needs "type": "module" and deps on @uhhm/*.`)
+98
View File
@@ -0,0 +1,98 @@
// The v7 → v8 source transform. Deliberately regex-based and honest about
// it: simple top-level CJS patterns are converted mechanically, package
// specifiers are remapped, and everything the regexes cannot prove is
// reported as a note instead of guessed at.
// old specifier → new specifier
export const SPECIFIERS = {
choo: '@uhhm/buuh',
'choo/html': '@uhhm/buuh-html',
'choo/html/raw': '@uhhm/buuh-html/raw',
'choo/component': '@uhhm/buuh-component',
nanohtml: '@uhhm/buuh-html',
'nanohtml/raw': '@uhhm/buuh-html/raw',
nanomorph: '@uhhm/buuh-html/morph',
nanocomponent: '@uhhm/buuh-component',
'choo-devtools': '@uhhm/buuh-devtools'
}
// old specifier → guidance (no direct replacement package)
export const RETIRED = {
nanobus: "built into @uhhm/buuh — use app.emitter, or import Nanobus from '@uhhm/buuh' internals is no longer needed",
nanorouter: 'built into @uhhm/buuh — app.route covers it',
nanohref: 'built into @uhhm/buuh — link handling is automatic',
nanotiming: "built into @uhhm/buuh — import from '@uhhm/buuh/timing' if you need it directly",
nanoraf: 'retired — use requestAnimationFrame, or rely on choo render batching',
nanoquery: 'retired — state.query is built in; use URLSearchParams directly elsewhere',
nanoassert: 'retired — use plain checks or node:assert',
nanolru: 'retired — the component cache is built into @uhhm/buuh',
'choo-lazy-route': "replaced by lazy() from '@uhhm/buuh': app.route('/x', lazy(() => import('./x.js')))",
'choo-service-worker': 'kept as a concept; bankai v10 will inject the asset manifest — check docs before migrating this one'
}
export default function transform (source) {
const notes = []
let code = source
// --- CJS → ESM, simple top-level forms ---
// const { a, b } = require('x')
code = code.replace(
/^(\s*)(?:var|let|const)\s*\{([^}]+)\}\s*=\s*require\((['"])([^'"]+)\3\)\s*;?[^\S\n]*$/gm,
(m, indent, names, q, spec) => `${indent}import {${names}} from '${spec}'`
)
// const X = require('x').default / .thing
code = code.replace(
/^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\.default\s*;?[^\S\n]*$/gm,
(m, indent, name, q, spec) => `${indent}import ${name} from '${spec}'`
)
code = code.replace(
/^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\.([A-Za-z_$][\w$]*)\s*;?[^\S\n]*$/gm,
(m, indent, name, q, spec, prop) => `${indent}import { ${prop} as ${name} } from '${spec}'`
)
// const X = require('x')
code = code.replace(
/^(\s*)(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*require\((['"])([^'"]+)\3\)\s*;?[^\S\n]*$/gm,
(m, indent, name, q, spec) => `${indent}import ${name} from '${spec}'`
)
// bare require('x') statements (side-effect imports)
code = code.replace(
/^(\s*)require\((['"])([^'"]+)\2\)\s*;?[^\S\n]*$/gm,
(m, indent, q, spec) => `${indent}import '${spec}'`
)
// module.exports = X
code = code.replace(/^(\s*)module\.exports\s*=\s*/gm, (m, indent, offset) => {
return `${indent}export default `
})
// --- specifier remapping (imports and any requires we couldn't convert) ---
code = code.replace(
/(from\s*|import\s*\(\s*|import\s+|require\s*\(\s*)(['"])([^'"]+)\2/g,
(m, lead, q, spec) => {
if (SPECIFIERS[spec]) return `${lead}${q}${SPECIFIERS[spec]}${q}`
if (RETIRED[spec]) {
notes.push(`'${spec}': ${RETIRED[spec]}`)
}
return m
}
)
// --- things we refuse to guess at ---
if (/module\.exports\.[A-Za-z_$]/.test(code)) {
notes.push('module.exports.<name> assignments found — convert to named `export` statements by hand')
}
if (/\brequire\s*\(/.test(code)) {
notes.push('require() calls remain (non-top-level or dynamic) — convert to import/await import() by hand')
}
if (/\bexports\.[A-Za-z_$]/.test(code)) {
notes.push('bare exports.<name> assignments found — convert to named `export` statements by hand')
}
return { code, changed: code !== source, notes: [...new Set(notes)] }
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@uhhm/buuh-migrate",
"version": "8.0.0",
"description": "Codemod: migrate choo v7 apps to buuh (choo v8) (ESM, new package names)",
"type": "module",
"bin": {
"buuh-migrate": "./cli.js"
},
"exports": {
".": "./lib/transform.js"
},
"files": [
"cli.js",
"lib"
],
"engines": {
"node": ">=24"
},
"repository": "https://project.uhhm.no/uhhm/buuh",
"keywords": [
"choo",
"codemod",
"migration"
],
"license": "MIT"
}
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test'
import assert from 'node:assert'
import transform from '../lib/transform.js'
test('converts a classic choo v7 header to v8 ESM', () => {
const src = [
"var choo = require('choo')",
"var html = require('choo/html')",
"var devtools = require('choo-devtools')",
'',
'var app = choo()',
"app.use(devtools())",
'module.exports = app'
].join('\n')
const { code, changed, notes } = transform(src)
assert.ok(changed)
assert.match(code, /import choo from '@uhhm\/buuh'/)
assert.match(code, /import html from '@uhhm\/buuh-html'/)
assert.match(code, /import devtools from '@uhhm\/buuh-devtools'/)
assert.match(code, /export default app/)
assert.deepStrictEqual(notes, [])
})
test('destructured and property requires', () => {
const src = [
"const { render } = require('some-lib')",
"const thing = require('other-lib').thing",
"const dflt = require('third-lib').default",
"require('./side-effect')"
].join('\n')
const { code } = transform(src)
assert.match(code, /import { render } from 'some-lib'/)
assert.match(code, /import { thing as thing } from 'other-lib'/)
assert.match(code, /import dflt from 'third-lib'/)
assert.match(code, /import '\.\/side-effect'/)
})
test('already-ESM sources get specifiers remapped', () => {
const src = [
"import choo from 'choo'",
"import html from 'nanohtml'",
"import raw from 'nanohtml/raw'",
"import morph from 'nanomorph'",
"import Component from 'nanocomponent'"
].join('\n')
const { code } = transform(src)
assert.match(code, /from '@uhhm\/buuh'/)
assert.match(code, /from '@uhhm\/buuh-html'\n/)
assert.match(code, /from '@uhhm\/buuh-html\/raw'/)
assert.match(code, /from '@uhhm\/buuh-html\/morph'/)
assert.match(code, /from '@uhhm\/buuh-component'/)
})
test('retired packages produce notes, not rewrites', () => {
const src = [
"var nanobus = require('nanobus')",
"var lazyRoute = require('choo-lazy-route')"
].join('\n')
const { code, notes } = transform(src)
assert.match(code, /from 'nanobus'/, 'retired specifier left for the human')
assert.ok(notes.some((n) => n.includes('nanobus')))
assert.ok(notes.some((n) => n.includes('lazy()')), 'choo-lazy-route points at lazy()')
})
test('dynamic import() specifiers are remapped too', () => {
const { code } = transform("const mod = await import('choo/html')")
assert.match(code, /import\('@uhhm\/buuh-html'\)/)
})
test('unconvertible CJS is reported honestly', () => {
const src = [
"module.exports.helper = function () {}",
"function f () { const x = require('choo') }"
].join('\n')
const { code, notes } = transform(src)
assert.match(code, /require\('@uhhm\/buuh'\)/, 'specifier remapped even inside functions')
assert.ok(notes.some((n) => n.includes('module.exports')))
assert.ok(notes.some((n) => n.includes('require() calls remain')))
})
test('non-choo sources pass through untouched', () => {
const src = "import fs from 'node:fs'\nexport const x = 1\n"
const { changed, notes } = transform(src)
assert.strictEqual(changed, false)
assert.deepStrictEqual(notes, [])
})
+55
View File
@@ -0,0 +1,55 @@
// Build the single-file CDN bundle: everything a browser needs as one
// minified ES module, for import-map use against any static host that
// serves JavaScript with the right MIME type.
//
// npm run bundle → dist-cdn/buuh.js (+ sourcemap)
//
// <script type="importmap">
// { "imports": { "buuh": "https://cdn.uhhm.no/buuh@8.0.0.js" } }
// </script>
// <script type="module">
// import { choo, html } from 'buuh'
// </script>
import { build } from 'vite'
import { readFile } from 'node:fs/promises'
import { join, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const { version } = JSON.parse(await readFile(join(root, 'packages/core/package.json'), 'utf8'))
const VIRTUAL = '\0bundle:entry'
await build({
appType: 'custom',
logLevel: 'warn',
plugins: [{
name: 'bundle-entry',
resolveId: (id) => id === 'bundle:entry' ? VIRTUAL : undefined,
load: (id) => id === VIRTUAL
? [
"export { default as choo, Choo, lazy } from '@uhhm/buuh'",
"export { default as html } from '@uhhm/buuh-html'",
"export { default as raw } from '@uhhm/buuh-html/raw'",
"export { default as morph } from '@uhhm/buuh-html/morph'",
"export { default as hydrate } from '@uhhm/buuh-html/hydrate'",
"export { default as Component } from '@uhhm/buuh-component'",
"export { default as devtools } from '@uhhm/buuh-devtools'"
].join('\n')
: undefined
}],
build: {
outDir: join(root, 'dist-cdn'),
emptyOutDir: true,
sourcemap: true,
lib: false,
rollupOptions: {
input: { buuh: 'bundle:entry' },
output: { entryFileNames: 'buuh.js' },
preserveEntrySignatures: 'strict'
}
}
})
console.log(`bundled buuh ${version} → dist-cdn/buuh.js`)
+64
View File
@@ -0,0 +1,64 @@
// The size budget: what does the framework itself cost on the wire?
// Bundles @uhhm/buuh + @uhhm/buuh-html (browser condition) minified via
// the same Vite/Rolldown pipeline apps use, then reports min+gzip and
// min+brotli. CI fails if min+gzip exceeds the budget.
//
// npm run size
import { build } from 'vite'
import { readFile, readdir, rm, mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
// The promise the README makes; raise only with a paper trail.
// Context: v7's "4kb" was choo alone — nanohtml lived in the browserify
// transform. This number is the whole framework: core, html engine,
// morph, hydrate, router, bus. Currently ~7.97 kB gzip; the headroom is
// margin, not license.
const BUDGET_GZIP = 8.5 * 1024
const VIRTUAL = '\0size:entry'
const outDir = await mkdtemp(join(tmpdir(), 'choo-size-'))
await build({
appType: 'custom',
logLevel: 'error',
plugins: [{
name: 'size-entry',
resolveId: (id) => id === 'size:entry' ? VIRTUAL : undefined,
load: (id) => id === VIRTUAL
? "import choo, { Choo, lazy } from '@uhhm/buuh'\n" +
"import html from '@uhhm/buuh-html'\n" +
"import raw from '@uhhm/buuh-html/raw'\n" +
'window.__keep = { choo, Choo, lazy, html, raw }\n'
: undefined
}],
build: {
outDir,
emptyOutDir: true,
rollupOptions: { input: { framework: 'size:entry' } }
}
})
const assets = await readdir(join(outDir, 'assets'))
const file = assets.find((name) => name.endsWith('.js'))
const code = await readFile(join(outDir, 'assets', file))
await rm(outDir, { recursive: true, force: true })
const gz = gzipSync(code, { level: constants.Z_BEST_COMPRESSION }).length
const br = brotliCompressSync(code, {
params: { [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY }
}).length
const kb = (n) => (n / 1024).toFixed(2) + ' kB'
console.log(`@uhhm/buuh + @uhhm/buuh-html (browser, minified)`)
console.log(` raw: ${kb(code.length)}`)
console.log(` gzip: ${kb(gz)} (budget ${kb(BUDGET_GZIP)})`)
console.log(` brotli: ${kb(br)}`)
if (gz > BUDGET_GZIP) {
console.error(`\nsize budget exceeded: ${kb(gz)} > ${kb(BUDGET_GZIP)} min+gzip`)
process.exit(1)
}
console.log('\nwithin budget ✔')
+72
View File
@@ -0,0 +1,72 @@
// bankai v10 end-to-end in real Chromium: the production pipeline
// (build → serve → SSR page hydrates and runs) and the dev server
// (Vite middleware + virtual entry + streaming SSR).
import { test, before, after } from 'node:test'
import assert from 'node:assert'
import { mkdtemp, rm } from 'node:fs/promises'
import { join, dirname } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import build from '../../packages/bankai/lib/build.js'
import serve from '../../packages/bankai/lib/serve.js'
import dev from '../../packages/bankai/lib/dev.js'
const repo = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const entry = join(repo, 'examples', 'counter', 'app.js')
let browser, outDir, prod, devSrv
before(async () => {
browser = await chromium.launch()
outDir = await mkdtemp(join(tmpdir(), 'bankai-e2e-'))
await build({ entry, outDir, title: 'counter' })
prod = await serve({ outDir, port: 0 })
devSrv = await dev({ entry, port: 0, title: 'counter dev' })
})
after(async () => {
await browser?.close()
await prod?.close()
await devSrv?.close()
if (outDir) await rm(outDir, { recursive: true, force: true })
})
async function openAndCount (origin) {
const page = await browser.newPage()
const errors = []
const warnings = []
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text())
if (msg.type() === 'warning') warnings.push(msg.text())
})
page.on('pageerror', (err) => errors.push(String(err)))
await page.goto(origin + '/')
await page.waitForFunction(() => document.querySelector('h1')?.textContent === 'count is 0')
await page.click('button')
await page.waitForFunction(() => document.querySelector('h1')?.textContent === 'count is 1')
return { page, errors, warnings }
}
test('production: built app serves, hydrates and runs with no errors', async () => {
const { page, errors, warnings } = await openAndCount(prod.origin)
assert.deepStrictEqual(errors, [], 'no console errors')
const mismatches = warnings.filter((w) => w.includes('markup differ'))
assert.deepStrictEqual(mismatches, [], 'no hydration mismatch warnings')
const state = await page.evaluate(() => window.choo ? 'devtools' : typeof window.initialState)
assert.strictEqual(state, 'undefined', 'choo consumed initialState (deleted after merge)')
await page.close()
})
test('dev server: Vite-transformed entry hydrates and runs', async () => {
const { page, errors } = await openAndCount(devSrv.origin)
assert.deepStrictEqual(errors, [], 'no console errors')
await page.close()
})
+96
View File
@@ -0,0 +1,96 @@
// Real-browser pass (Playwright Chromium): the zero-build page and the
// SSR + hydration page, with console output treated as part of the spec —
// no errors, no hydration warnings.
//
// Run with: npm run test:e2e (needs `npx playwright install chromium-headless-shell`)
import { test, before, after } from 'node:test'
import assert from 'node:assert'
import { chromium } from 'playwright'
import { startServer } from './serve.js'
let browser, ctx, srv
before(async () => {
srv = await startServer()
browser = await chromium.launch()
ctx = await browser.newContext()
})
after(async () => {
await browser?.close()
await srv?.close()
})
// renders are raf-batched, so poll instead of reading synchronously
function waitForCount (page, text) {
return page.waitForFunction(
(t) => document.querySelector('h1')?.textContent === t,
text,
{ timeout: 5000 }
)
}
async function openPage (path) {
const page = await ctx.newPage()
const console_ = { errors: [], warnings: [] }
page.on('console', (msg) => {
if (msg.type() === 'error') console_.errors.push(msg.text())
if (msg.type() === 'warning') console_.warnings.push(msg.text())
})
page.on('pageerror', (err) => console_.errors.push(String(err)))
await page.goto(srv.origin + path)
return { page, console_ }
}
test('zero-build page: import map + native ESM, no bundler', async () => {
const { page, console_ } = await openPage('/examples/counter/')
await waitForCount(page, 'count is 0')
await page.click('button')
await waitForCount(page, 'count is 1')
await page.click('button')
await page.click('button')
await waitForCount(page, 'count is 3')
assert.deepStrictEqual(console_.errors, [], 'no console errors')
await page.close()
})
test('SSR page: content before JavaScript, then live after hydration', async () => {
// the raw response must already contain the rendered view
const res = await fetch(srv.origin + '/ssr')
const rawHtml = await res.text()
assert.match(rawHtml, /count is 0/, 'server sent rendered markup')
const { page, console_ } = await openPage('/ssr')
await waitForCount(page, 'count is 0')
// hydrated: the server DOM must now respond to clicks
await page.click('button')
await waitForCount(page, 'count is 1')
assert.deepStrictEqual(console_.errors, [], 'no console errors')
const mismatches = console_.warnings.filter((w) => w.includes('markup differ'))
assert.deepStrictEqual(mismatches, [], 'no hydration mismatch warnings')
await page.close()
})
test('SSR page: server DOM is adopted, not replaced', async () => {
const { page } = await openPage('/ssr')
await waitForCount(page, 'count is 0')
// Prove adoption by a surviving expando: mark the node, force a real
// render, and confirm the same node object still holds the marker.
await page.evaluate(() => {
document.querySelector('h1').__marker = 'server-node'
})
await page.click('button')
await waitForCount(page, 'count is 1')
const marker = await page.evaluate(() => document.querySelector('h1').__marker)
assert.strictEqual(marker, 'server-node', 'h1 survived that render in place')
await page.close()
})
+106
View File
@@ -0,0 +1,106 @@
// Test server for e2e runs: static files from the repo root plus /ssr,
// which server-renders the counter app and serves a hydration page — the
// same app module the import map hands to the browser.
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join, normalize, extname, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Readable } from 'node:stream'
import createApp from '../../examples/counter/app.js'
import createStreamingApp from '../../examples/streaming/app.js'
const root = normalize(join(dirname(fileURLToPath(import.meta.url)), '..', '..'))
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8'
}
const IMPORT_MAP = JSON.stringify({
imports: {
'@uhhm/buuh': '/packages/core/index.js',
'@uhhm/buuh/timing': '/packages/core/lib/timing.js',
'@uhhm/buuh-html': '/packages/html/browser.js',
'@uhhm/buuh-html/raw': '/packages/html/raw.js',
'@uhhm/buuh-html/morph': '/packages/html/morph.js',
'@uhhm/buuh-html/hydrate': '/packages/html/hydrate.js'
}
})
function ssrPage () {
const body = createApp().toString('/')
// When a view owns <body>, scripts belong in <head>: type="module" is
// deferred by definition, and body must contain only what the view
// renders or hydration would (rightly) flag the extra nodes.
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>choo v8 ssr counter</title>
<script type="importmap">${IMPORT_MAP}</script>
<script type="module">
import createApp from '/examples/counter/app.js'
createApp().mount('body')
</script>
</head>
${body}
</html>`
}
export function startServer () {
const server = createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost')
if (url.pathname === '/ssr') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(ssrPage())
return
}
if (url.pathname === '/stream') {
const delay = Number(url.searchParams.get('delay')) || 500
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
// padding nudges browsers to start parsing before the stream closes
res.write('<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>choo streams</title></head>\n' + '<!-- ' + ' '.repeat(1024) + ' -->\n')
const body = Readable.fromWeb(createStreamingApp({ delay }).toStream('/'))
body.pipe(res, { end: false })
body.on('end', () => res.end('\n</html>'))
body.on('error', () => res.destroy())
return
}
let pathname = decodeURIComponent(url.pathname)
if (pathname.endsWith('/')) pathname += 'index.html'
const file = normalize(join(root, pathname))
if (!file.startsWith(root)) {
res.writeHead(403).end()
return
}
try {
const data = await readFile(file)
res.writeHead(200, {
'content-type': MIME[extname(file)] || 'application/octet-stream'
})
res.end(data)
} catch (e) {
res.writeHead(404).end('not found: ' + pathname)
}
})
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
server,
origin: `http://127.0.0.1:${server.address().port}`,
close: () => new Promise((r) => server.close(r))
})
})
})
}
+57
View File
@@ -0,0 +1,57 @@
// Streaming SSR, proven at two levels: raw chunk timing over HTTP, and
// progressive DOM construction in real Chromium.
import { test, before, after } from 'node:test'
import assert from 'node:assert'
import { chromium } from 'playwright'
import { startServer } from './serve.js'
const DELAY = 500
let browser, srv
before(async () => {
srv = await startServer()
browser = await chromium.launch()
})
after(async () => {
await browser?.close()
await srv?.close()
})
test('HTTP level: shell bytes arrive before the slow section resolves', async () => {
const started = performance.now()
const res = await fetch(`${srv.origin}/stream?delay=${DELAY}`)
const decoder = new TextDecoder()
let sawShellAt = null
let text = ''
for await (const chunk of res.body) {
text += decoder.decode(chunk, { stream: true })
if (sawShellAt === null && text.includes('<h1>choo streams</h1>')) {
sawShellAt = performance.now() - started
assert.ok(!text.includes('id="slow"'), 'slow section not in the early bytes')
}
}
assert.ok(sawShellAt !== null, 'shell was seen')
assert.ok(sawShellAt < DELAY, `shell arrived at ${sawShellAt.toFixed(0)}ms, before the ${DELAY}ms hole`)
assert.match(text, /id="slow"/, 'slow section arrived in the same response')
assert.match(text, /<\/html>/, 'document completed')
})
test('browser level: Chromium builds the shell DOM before the stream ends', async () => {
const page = await browser.newPage()
await page.goto(`${srv.origin}/stream?delay=${DELAY}`, { waitUntil: 'commit' })
// the shell must be in the DOM while the slow section is still absent
await page.waitForSelector('h1', { timeout: DELAY - 100 })
assert.strictEqual(await page.$('#slow'), null, 'slow section not parsed yet')
// then the same document grows the slow section without navigation
await page.waitForSelector('#slow', { timeout: DELAY * 4 })
assert.match(await page.textContent('#slow'), /500ms later/)
await page.close()
})