Skip to content

feat: htmlbin serve — local preview before publish#5

Open
utsengar wants to merge 4 commits into
mainfrom
feat/serve-local-preview
Open

feat: htmlbin serve — local preview before publish#5
utsengar wants to merge 4 commits into
mainfrom
feat/serve-local-preview

Conversation

@utsengar

@utsengar utsengar commented May 23, 2026

Copy link
Copy Markdown
Owner

Summary

Adds htmlbin serve — a local preview server for HTML and Markdown so iteration happens locally before publishing. Same file in, same artifact out: htmlbin serve out.html → review locally → htmlbin publish out.html. Also gives the CLI a branded help screen.

htmlbin serve <path>     start (file or directory; live-reload on save)
htmlbin serve --status   inspect the running server in this cwd
htmlbin serve --stop     shut it down

Highlights

  • One command, file or directory. .html served raw; .md rendered via marked with the same prose tokens as htmlbin.dev. Directory mode shows an mtime-sorted index.
  • Chrome matches the production drop viewer-bar. Full-width, Geist sans title, mono meta column, red [local] chip + connection dot. Appended to documentElement (not body) and position: fixed, so it survives user pages with max-width containers, transform, or filter. Body padding-top is extended at mount so content sits below the bar, not behind it.
  • publish → link in the bar. Click opens a small popover with htmlbin publish <abs-path> and a Copy command button. No network calls from the server — bar stays passive. Shown only for .html/.htm.
  • External client assets. The bar's CSS and JS live at /__hb/client.css and /__hb/client.js, not inline, so pages with Content-Security-Policy: script-src 'self' (and style-src 'self') still load the bar.
  • CSS reset resilience. All bar positioning carries !important, so user CSS like * { all: revert !important } can't override it.
  • Live reload over SSE, zero client deps. fs.watch with 50ms debounce and a dotfile/node_modules filter (so the state file at .htmlbin/serve.json doesn't fire phantom reloads).
  • Symlink-safe. renderDir does a fs.realpath() check to confirm the resolved path is still under the served root; the directory walker uses lstat and skips symlinks.
  • Agent-first contract: one JSON object per line on stdout (started, reload, client-connected, idle-shutdown, stopped) when --output json. Lets an agent tail the background process and verify "did my edit reach the browser?" without polling.
  • State file at ./.htmlbin/serve.json is the cross-invocation discovery primitive — a later turn can call --status / --stop without parsing logs.
  • Auto-detected agent defaults: when CLAUDE_CODE / CURSOR_AGENT / etc. is set in the env, browser auto-open flips off and idle timeout drops from 30 → 5 min.
  • Duplicate-start guard with PID liveness check; clean SIGINT / SIGTERM shutdown; ephemeral port by default, --port to override.
  • Branded htmlbin --help. Sectioned layout (commands / flags / environment variables / try / learn-more) with an ASCII wordmark, gated on TTY + NO_COLOR so piped output stays plain. htmlbin with no subcommand now lands on this help instead of commander's "missing required command" error. Subcommands keep commander's default per-command help.

New runtime dep: marked.

Files

src/serve/
  index.ts       orchestration (start / status / stop)
  server.ts      http.createServer + SSE broadcast hub + /__hb/* routes
  render.ts      path → response (HTML pass-through, MD render, dir index, 404, realpath/symlink check)
  inject.ts      builds the <link> + <script src> tags appended to served HTML
  client.ts      bar runtime + styles, served as static assets
  template.ts    chrome + prose tokens for content we own (MD, index, 404)
  watcher.ts     fs.watch wrapper with debounce + dotfile filter
  state.ts       .htmlbin/serve.json read/write + PID liveness
  types.ts       ServeOptions, ServeState

src/banner.ts    branded help formatter for the root command
src/bin.ts       registerServeCommand + helpInformation override
src/errors.ts    + serve_already_running / serve_not_running / serve_port_in_use

Stress tests passed

  • 100 concurrent SSE clients connected; 50/50 received a reload broadcast
  • 20 rapid edits collapse to 1 reload (50ms debounce)
  • Atomic save (write-tmp + rename) → exactly 1 reload
  • 100 files added in a dir → 2 reloads, server responsive in ~25ms
  • Path traversal (../, %2E%2E%2F, double-encoded, null byte) → 404
  • Symlink inside the served root pointing at /etc/passwd → 404 (after hardening)
  • kill -9 → stale state file is cleared on next start
  • Port collision → serve_port_in_use (exit 8)
  • serve --stop twice → second returns serve_not_running (exit 7)

Test plan

  • htmlbin serve <file.html> — bar renders full-width with [local] chip; edit file → browser reloads
  • User HTML content sits below the bar, not behind it (works on pages with and without their own body { padding })
  • publish → link opens a popover with htmlbin publish /abs/path + Copy button; click outside or Esc dismisses
  • htmlbin serve <file.md> — Markdown renders with chrome at top; no publish button
  • htmlbin serve <dir> — index page lists .html/.md sorted by mtime
  • htmlbin serve --status / --stop — round-trip with state file at .htmlbin/serve.json
  • Duplicate htmlbin serve <path> while one is running → exit 7
  • htmlbin serve --output json — one event per line on stdout
  • Edit a file → exactly one reload event (state-file write doesn't echo)
  • htmlbin (no args) and htmlbin --help show the branded banner in a TTY, plain text when piped
  • htmlbin serve --help (and other subcommand --help) still uses commander's default

🤖 Generated with Claude Code

utsengar and others added 4 commits May 23, 2026 15:55
Adds a local server so HTML and Markdown can be iterated on locally and
then shipped via the same `publish` flow. Same file in, same artifact
out — the local view is a tight feedback loop, not a separate format.

Commands:
  htmlbin serve <path>     start (file or directory; auto-reloads on save)
  htmlbin serve --status   inspect the running server in this cwd
  htmlbin serve --stop     shut it down

Notable choices:
- File or directory in one command. .html served raw; .md rendered via
  marked with the same prose tokens as htmlbin.dev. Directory mode shows
  an index sorted by mtime.
- Injected chrome mirrors the production drop viewer-bar: full-width,
  Geist sans for the title, mono meta column, and a red [local] chip as
  the "this isn't a published drop" signal. Position: fixed so it
  escapes any max-width on the user's body.
- Live reload over SSE, no client deps. fs.watch with 50ms debounce and
  a dotfile/node_modules filter (so the state file at .htmlbin/serve.json
  doesn't fire phantom reloads).
- Agent-first: one JSON event per line on stdout (started, reload,
  client-connected, idle-shutdown, stopped) when --output json. Browser
  auto-open and idle timeout flip to agent-friendly defaults when
  CLAUDE_CODE / CURSOR_AGENT / etc. is set. State file is the
  cross-turn discovery primitive.
- Duplicate-start guard with PID liveness check; clean SIGINT/SIGTERM
  shutdown; ephemeral port with explicit --port override.

New dep: marked (Markdown rendering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups based on real-world testing of the injected viewer-bar:

- Removed the × close button. Production drops have no close on the
  viewer-bar; `--no-overlay` already covers the "I want this off" case.

- Fixed content hiding behind the bar. With `position: fixed`, the user's
  HTML starts at y=0 and the first ~40px sat under the bar. At mount we
  now add the bar's height to body's existing `padding-top` (preserving
  whatever was there) and keep it synced via ResizeObserver, so wrapped
  rows on narrow viewports still get the right offset.

- Added a `publish →` link on the right side. Click opens a small popover
  with `htmlbin publish <abs-path>` and a Copy command button. Uses the
  absolute path so the copied command works regardless of where it's
  pasted. No network calls from the server — bar stays passive.

Only `.html`/`.htm` files get the publish affordance (Markdown isn't
directly publishable). `render.ts` threads the absolute path through as
`publishPath`; empty string hides the button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four fixes from stress testing the injected viewer-bar against pages
that try to break it, and the static file server against escape attempts:

- Symlink escape (path safety). renderDir's lexical path check let a
  symlink inside the served root point at arbitrary files (e.g. a
  symlink to /etc/passwd inside the dir was served at 200). Now adds
  a fs.realpath() check that re-verifies the resolved path is still
  under realpath(rootAbs). The directory walker also switches from
  stat to lstat and skips symlinks, so they don't appear in the index
  and we never recurse into a symlinked subdir.

- Page CSP. Inline <style> and <script> blocks were rejected by pages
  with Content-Security-Policy: script-src 'self' / style-src 'self'.
  Moved the bar's CSS and JS into a new client.ts module served as
  static assets at /__hb/client.css and /__hb/client.js, and changed
  inject.ts to emit <link> + <script src> tags. Same-origin requests
  satisfy 'self' policies. Per-page config (path, addr, publishPath,
  reload, overlay) is passed via query string and read by the script
  from its own document.currentScript.src.

- CSS reset escape. Pages with `* { all: revert !important }` or
  similar aggressive resets could override the bar's positioning.
  Added !important to all bar layout properties and the scoped
  `all: unset` reset, so user !important rules can't beat us.

- Transform-as-containing-block trap. position:fixed resolves
  relative to the nearest ancestor with transform / filter /
  will-change / perspective, not the viewport. A page with
  `body { transform: ... }` (very common in animation-heavy sites)
  trapped the bar inside body's box, collapsing it back to the
  user's max-width. Bar and popover now append to
  document.documentElement instead of document.body, so they
  escape body's containing block.

Stress-test results that still pass unchanged: 100 concurrent SSE
clients, 50-client reload broadcasts, 50ms debounce on rapid edits
(20 writes → 1 reload), atomic-save handling, SIGKILL-stale-state
recovery, port-collision error, status-from-other-cwd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces commander's default root help with a sectioned layout
(commands / flags / environment variables / try / learn-more) and
a chunky ASCII wordmark across the top.

Gating:
- ASCII logo + ANSI colors only when stdout is a TTY and NO_COLOR
  is unset. Piped runs (agents, CI, redirected captures) get the
  same content as plain text, no banner.
- Reassigns `program.helpInformation` on the root command only —
  avoiding configureHelp() which would propagate to subcommands.
  `htmlbin <cmd> --help` still uses commander's default per-command
  formatter (the right shape there).
- `htmlbin` with no subcommand now shows the help instead of
  commander's "missing required command" error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant