Server Architecture

Bascik's server infrastructure powers both local development (bascik) and per-request production serving (bascik --server). Designed as a modular 4-tier pipeline, the server handles request routing, static file serving, data-bascik-server request script execution, live reload SSE streams, in-memory caching, port environment overrides, and security hardening.

Modular Architecture (server.ts, http.ts, http2.ts, pki.ts)

Bascik separates protocol management from request routing using a 4-tier architecture:

text
[server-dev.ts (Dev) / server-prod.ts (Prod)]
                          │
                          ▼ (startServer)
                 [server.ts Orchestrator]
                          │
                 ┌────────┴────────┐
                 │ (tls.enabled:   │ (tls.enabled:
                 │  false)         │  true)
                 ▼                 ▼
             [http.ts]          [pki.ts Cert Gen]
          (HTTP/1.1 Server)        │
                 │                 ▼
                 │             [http2.ts]
                 │          (HTTP/2 Server)
                 │                 │
                 └────────┬────────┘
                          ▼
           [server.ts: createRequestHandler]
                          │
         ┌────────────────┴────────────────┐
         ▼ (HTML pages, both modes)         ▼ (non-HTML static assets, both modes)
  [In-Memory Store (mem.ts)]        [Disk Filesystem (dist/ or on-the-fly)]
         │                                 │
         └────────────────┬────────────────┘
                          ▼
         [data-bascik-server Execution]
                          │
                          ▼
                  [HTTP Response]
  1. server.ts: Central server orchestrator. Defines the unified createRequestHandler() routing pipeline, startServerInstance() port binder, rate limiter, security header handler, and top-level startServer() dispatcher.
  2. http.ts: Plaintext HTTP/1.1 server (node:http). Wraps http.IncomingMessage and http.ServerResponse into Bascik's request context.
  3. http2.ts: Opt-in encrypted HTTP/2 server (node:http2). Wraps ServerHttp2Stream into Bascik's request context.
  4. pki.ts: Generates TLS certificates when http.tls.enabled: true is configured and certificate files are missing on disk.

Plaintext HTTP/1.1 Default vs HTTP/2 TLS

Plaintext HTTP/1.1 is active by default because it works instantly across all local development tools and integrated browsers without untrusted certificate warnings or platform trust setup.

For production HTTP/2 protocol parity during local development, enable TLS in bascik.config.ts:

ts
export default {
  http: {
    tls: {
      enabled: true, // Server boots over https://localhost:8443 (HTTP/2)
    },
  },
};

When http.tls.enabled: true is active, pki.ts looks for local bascik-cert.pem and bascik-privkey.pem files. If missing, it attempts to generate CA-trusted certificates using mkcert (if available) or falls back to OpenSSL self-signed certificates. Present files are parsed with X509Certificate: an expired or not-yet-valid default certificate is regenerated with a warning, and a configured certFile in that state is kept and warned about, never replaced.

Adapter Development Mode (bascik --target)

bascik --target cloudflare and bascik --target netlify use a separate development path from the in-memory Node server below:

  1. Run a complete production compilation and adapter build, rather than a selective in-memory page update.
  2. Start the real platform runtime: Wrangler's local workerd for Cloudflare, or Netlify Dev from the project root after installing the Netlify output. Each start listens on a freshly allocated loopback port, so a restart never depends on the previous runtime's port still being free.
  3. Expose a local HTTP proxy at http://127.0.0.1:8787 for Cloudflare or http://127.0.0.1:8888 for Netlify, with --port and --host overrides.
  4. Watch source changes, rebuild the complete adapter bundle, and notify connected browsers to reload. Live-reload code is injected into local HTML responses, not written to the emitted production artifacts.

Request-time scripts and API routes run in the platform runtime, not the Node server's script registry. The proxy is HTTP only; http.tls.enabled applies to the Node server, not this proxy. Requests may receive 503 while rebuilding or after a failed build. The watcher survives compilation failures and retries on the next source edit; it does not guarantee last-good serving.

Cloudflare local bindings persist under the project's .wrangler/state/, not the generated adapter output. Rebuilding source files therefore preserves local KV, D1, R2, and Durable Object state.

When no site URL is configured, adapter development uses the local proxy origin for sitemap, robots, and canonical URL generation. An explicit site URL, including one in .env, takes precedence. Production builds still require a configured site URL when those generators are enabled.

Bare bascik keeps its existing in-memory authoring path. bascik --build --target ... remains a one-shot production build with no watcher, proxy, or live-reload injection. Direct Wrangler and Netlify previews of that output remain production acceptance paths.

Platform child processes inherit terminal output, but not input: Bascik owns the development session and Ctrl+C shutdown. Platform-specific interactive exit hotkeys are disabled. If the platform runtime exits successfully, the development session closes without a retry error. Unexpected unsuccessful runtime exits are reported and can be retried by saving a source file.

Development Server Mode (bascik)

During local development, bascik compiles pages into memory and starts the watch and live-reload systems.

In-memory page store (mem.ts)

The MemoryStore class manages rendered pages during development without writing intermediate files to disk on every edit:

  • #files: Maps HTTP paths (such as /getting-started) to StoredPage objects containing raw HTML buffers, pre-compressed Brotli and Gzip buffers, and component usage lists. getPageExact performs O(1) exact lookups and handles trailing-slash path resolution (/blog vs /blog/) directly without redundant Map queries.
  • #components: Inverted index mapping each component name to the Set<string> of page paths using it. This index enables selective re-transpilation when a single component changes. A stylesheet imported by component CSS reaches this index through componentsImportingStylesheet in components.ts, which maps the imported path to the importing component names; every dev watcher that sees the file (components, pages assets, or the combined source observer) rebuilds those pages before reloading.
  • #openPages: Tracks active SSE live-reload connections by HTTP path. Pages currently open in a browser tab are transpiled first during batch rebuilds (processPageBatch and processAllPages) so visible tabs refresh immediately without waiting for background pages. The boot page opens its /bascik-live-reload?boot=1 stream with the requested page as the Referer, so a page requested while the first compile is still running is tracked as open.

Brotli compression during development uses minimum quality (BROTLI_MIN_QUALITY = 1) for instant background compression without clogging Node.js C++ threadpool workers. Under --build and --server, Brotli compression uses maximum quality (BROTLI_MAX_QUALITY = 11) to ensure optimal payload sizes.

Every stored page is also gzip-compressed in the background (Z_BEST_SPEED in dev, Z_BEST_COMPRESSION in build and production) as a fallback for clients whose Accept-Encoding does not include br: older browsers, some corporate proxies, and many crawlers. On each request the server picks the best encoding the client accepts that is already computed: br, then gzip, then identity. A page is servable immediately after store; if a client asks for br before the Brotli job finishes, it receives gzip (if ready) or the raw bytes rather than waiting. Each background job checks that the page has not been re-stored in the meantime before attaching its result, so a stale compression result never shadows newer content.

Error page handling

Custom 404 and 500 error pages are supported by filesystem convention:

  • /404: Rendered from src/pages/404.html.
  • /500: Rendered from src/pages/500.html. When an unhandled error occurs during request processing, onError serves /500 with status 500. If missing, it falls back to a clean built-in HTML document. Server stack traces are sent to stderr only and never leaked to response payloads.

Boot page during startup (boot-page.ts)

The development server binds its port immediately while page transpilation runs asynchronously. Requests arriving before a page finishes transpiling receive a lightweight boot page displaying a spinner and status message. The message is a role="status" paragraph inside <main>, and the spinner stops under prefers-reduced-motion: reduce.

The boot page connects to /bascik-live-reload?boot=1 with the requested URL as its Referer. It does not wait for the "transpiled" event: during boot that event is buffered by the boot publication scope until the post phase has run (see compilation-events.ts below). Instead mem.storePage emits a never-buffered "page-stored" event the moment a page is in memory, and the SSE handler reloads a boot client when that event names its page, or immediately on connect when the page is already stored. That reload is sent once and retires every other reload listener on the boot connection, which the browser closes as it navigates. The real page then opens an ordinary live-reload connection, and that connection reloads once more at boot end: the boot publication scope flushes the buffered "transpiled" event for its page after post, so the tab picks up post outputs. The handler does not wire boot-done for a connection whose page is already in memory, because it fires milliseconds after that flush and would reload the same tab a second time. boot-done still reloads a boot client whose page never compiled (it lands on the 404 page) or whose connection carries no Referer.

A component that fails to load at boot (a TypeScript syntax error in its <script>, for example) does not end the process. watch.ts prints the located message, records it in boot-failure.ts, and finishes boot. While that failure stands, unmatched page requests still get the boot page, and a boot live-reload connection with no page in memory receives the failure as a build-error event, so the overlay names the file instead of a 404. The next change or deletion in a components root rebuilds every page and clears the failure (or records the next one). This applies to the watcher branch without watched exec entries; with them, the source observer publishes the error as before. Once watchFiles() completes the initial build, the isBooting flag is cleared and unmatched paths fall through to 404 handling. The boot page is never used in production mode.

Startup logging: After the awaited pre phase, startServer() binds while page compilation runs. In an interactive terminal the server URL is pinned to the top row as soon as the port is bound (lib/pinned-banner.ts, turned off by --no-pin or BASCIK_PIN_SERVER_URL=0; there is deliberately no config key, because it is a per-terminal choice). The banner ends with one written space after the URL: VS Code's terminal can treat the second half of a wrapped log line as a continuation of the pinned row once the first half scrolls away, and without the space its link detection glued that text onto the URL. In piped output and CI the URL is printed after the initial compilation and post phase finish. Parallel exec tasks are not awaited in dev and may log completion later.

The pin does not scroll the screen. It asks the terminal where the cursor is, inserts one blank line at the top so everything already on screen moves down a row, draws the banner there, and sets a scroll region from row 2 down. Only a completely full screen loses a line, exactly one.

In VS Code's integrated terminal with shell integration on (TERM_PROGRAM=vscode and VSCODE_INJECTION set), the scrollback is also erased, the way clear does it. That terminal draws the command line of whichever command owns the top visible row as a sticky overlay across that row, as soon as that command's line has scrolled out of view. Any scrollback above the screen belongs to earlier commands, so the overlay would cover the banner. It stays hidden only while nothing above the screen can own the top row. Other terminals have no such overlay and keep their scrollback.

Watch system (watch.ts)

Without watched exec entries, separate native filesystem watchers (chokidar) handle source file updates:

  1. Static assets watcher: Copies non-HTML files in pages/ to dist/ on add or change, deletes them on unlink, and triggers a live-reload event.
  2. Page HTML watcher: Listens for .html file changes in pages/. Triggers full or single-page transpilation and updates MemoryStore. Its ready fires before the boot compile ends, so a page added or deleted in between (and an inline stylesheet edited in between, from the static assets watcher) is recorded instead of starting a full rebuild alongside the boot compile. When the boot compile finishes, deleted pages are removed again (the boot compile may have stored them) and one coalesced full rebuild runs before boot completes, so the new page is served when boot-done fires.
  3. Component watcher: Listens for changes in every configured directory.components root. This is the only watcher with followSymlinks: true, so a symlinked directory inside a root triggers rebuilds; chokidar reports the link path, which is what the inverted component index expects. On change or deletion, uses the inverted component index (#components) to selectively rebuild only affected pages, plus the pages whose scripts imported the changed file (pagesDependentOnFile): a module colocated in a component folder, such as faq-list/faqs.ts, names no component, so only its importers link it to a page.
  4. Import-root watcher: Listens under scripts.importRoot and advances request-time module identities so an edited src= server script reloads on the next request. It does not compile pages. directory.pages and every directory.components root are excluded when nested inside it.

Every watcher has an error listener. Chokidar 6 reports a folder it cannot read or watch (EACCES, EMFILE) as an error event, and an error event with no listener would end the process. A failure before the page HTML watcher (or the single source observer) is ready fails startup with that error; later failures are logged as [bascik] watch error: (or published as a build error by the source observer) and the dev server keeps running. An error that already carries its location (its message starts with [bascik], or it is marked located, as a TypeScript transform error is) is logged as its message alone, the same lines bascik --build prints; only an unexpected error type keeps its stack.

With watched exec entries, watch-source.ts replaces the asset, page, component, and extra-path observers with one source observer. It watches the union of source roots and literal roots extracted from globs, filters events using the configured patterns, and excludes directory.out. Runtime import-root and API observers remain separate. Overlapping page, content, and exec watches therefore cannot race into duplicate compilation.

source-cycle.ts debounces source paths into one serialized phase cycle. Matching pre scripts run sequentially before compilation; matching parallel scripts start alongside compilation; matching post scripts run after compilation and disk writes finish. The dependency graph and component index select a deduplicated page batch. External inputs with no known dependents fall back to all pages, but never rerun unmatched exec entries. Edits received during a cycle or initial compilation are retained. Failed paths are retried on a later source edit, not automatically in a loop.

compilation-events.ts uses async-local publication scopes to hold page reloads and join dev disk writes through post. Each scope retires only its own store, so nested or overlapping compilations cannot disable one another's buffered writes, reloads, or page-error policy. In dev boot, that scope wraps only the initial compile callback. The legacy watch.ts branch installs its component and pipeline.watchPaths compilation watchers after that initial boot compile finishes, so their callbacks execute outside the boot publisher. Like the source observer, its pipeline.watchPaths watcher observes each glob's literal root and ignores events for files no pattern matches, because chokidar does not expand globs. Both watchers take their ignored function from createSourceWatchIgnore (watch-root.ts): the output directory and its private .bascik folder are never watched, and neither is a node_modules or .git folder below a watch root, so a root-level glob such as **/*.md does not walk installed packages; a root named inside node_modules is still watched. The watch-source.ts path retains the same startup safety through its deferred source queue. Subsequent edits publish directly from the legacy watchers or through a fresh per-edit scope in source-cycle.ts. When no post script matches the edit, source-cycle.ts does not hold the reload of a page open in a browser: watch-source.ts passes publishEarly, and each transpiled event for an open page is emitted the moment that page is stored, ahead of the pages still compiling, so editing a component used on every page still reloads the open tab after one page's compile. Every other page's reload is still held until the cycle ends, and every reload is held while a post script matches. A pre, compile, write, or post failure reports a build-error and discards the held success reloads for that cycle. Boot page errors are published to the overlay instead of exiting the dev process, while build mode and edit cycles still throw honestly. Memory pages are not transactionally rolled back; a manual request may see newly compiled HTML before post finishes, and a boot page reloads on page-stored at that same moment, so post outputs may still be pending when it first renders; the flushed transpiled event reloads it again once they exist. Exec completion never schedules another compilation. Build helpers and external inlined stylesheets need pipeline.watchPaths or exec input patterns; neither scripts.importRoot nor assets.inlineStyles adds a compilation watch.

Exec artifacts must go only to dist/, never source or watched paths. Do not watch generated files. Chokidar events do not identify the writer, so distinguishing a legitimate edit from an accidental exec write is unsafe. No time-window, self-script, or repeat-count suppression hides edits; scripts that write watched sources can still loop and must be corrected.

When a page's build fails because a helper does not exist yet, Bascik records that dependency in a failed-dependency index. If a compilation watch covers the helper, creating or repairing it routes through that index so the page can recover. The index selects pages but does not create watchers. It is drained on deletion and when the page's import set changes. A page that fails inside a page worker (pipeline.workers: true) sends its missing helpers back with the failure, and the main thread records them, since the watchers read the main thread's index, never a worker's.

Live reload (live-reload.ts, sse.ts)

Live reload uses Server-Sent Events (SSE) via GET /bascik-live-reload. Bascik injects a lightweight SSE client script into HTML pages in development mode. The SSE system features:

  • Monotonic Generation Counter: Reload events include an incrementing integer generation counter (data: reload <gen>). The client-side script tracks lastGeneration and ignores stale, duplicate, or out-of-order reload messages.
  • Page Versions: Each compiled page's live-reload script carries a version (data-bascik-live-reload-version), and a connecting page receives the stored page's version (data: connected <version>) and reloads when the two differ, which catches a tab that loaded the old page just before its rebuild's reload went out. A version is unique in the process across threads: it is the compiling thread's threadId times 2^32 plus that thread's own count, so a page re-stamped by a fresh worker pool (every full dev rebuild starts one) never gets the version it had.
  • Reload Coordination: Reload notifications from asset updates, explicitly watched paths, and page transpilation use SSE generation tracking. Exec completion never requests a reload.
  • Phase-ordered Exec Lifecycle: pipeline.exec[].watch selects matching scripts for the source-owned cycle described above. transpile.ts starts parallel tasks without awaiting them in dev; startExecDev observes each outcome. exec-publication.ts maps startup parallel failures to located build-errors, with no completion listener. The source cycle reports watched failures directly, and a cycle fails only for a build-error raised by its own work (its pre, parallel and post scripts and its compile, tracked with an AsyncLocalStorage scope), never for one from a parallel script an earlier cycle started, a startup parallel script or a watcher error that happens to land while it compiles. exec-policy.ts owns pipeline.onExecError: under the dev default ('warn') every failure, at startup or in a cycle, stays a build-error and the server keeps running; under 'error' startup failures reject the boot, and later ones (cycle, parallel) run the shutdown handlers and exit 1. A build defaults to 'error'. Parallel completion never requests a reload and cannot retract reloads already delivered before a later failure. Build runs parallel alongside compilation and joins both branches before reporting success.
  • Monotonic Publication: Every compilation entrypoint assigns the page a monotonic generation and only applies side effects (memory, disk, dependency index, sidecar, reload event) if that generation is still latest. A stale batch that finishes after a newer direct edit cannot overwrite it; a deleted page cannot be resurrected by late work. See Generation Ownership.
  • Periodic Heartbeats: Sends : ping\n\n comments every 20 seconds, preventing proxy/VPN idle disconnection.
  • Bounded Backpressure: Honors res.write() return values. Each connection owns exactly one drain subscription, so a burst of backpressured writes cannot accumulate listeners; writes stop until the watcher drains, and the single listener is removed on client removal or manager destroy. Persistently wedged clients are terminated after a heartbeat threshold.
  • Connection Cap & Cleanup: Bounded at 200 concurrent SSE streams (DEFAULT_MAX_SSE_CONNECTIONS). The handler checks the cap before it commits the 200 event-stream headers, so a connection over the cap gets 503 Too Many SSE Connections and the client's retry and offline banner take over; open-page tracking starts only once a client is registered. Stream close immediately removes the client from the manager and keeps open-page tracking balanced, rather than waiting for the next heartbeat.
  • Build Error Overlay: The SseManager owns exactly one build-error subscription on the event bus and routes a located failure (file, line, column) to every live client exactly once, so N connections never produce N broadcasts. Errors clear on subsequent successful builds. In the browser the overlay is a native <dialog> with role="alertdialog", opened with showModal(): it sits in the top layer above any dialog or popover the page has open, moves focus to Dismiss, closes on Escape, and returns focus when it closes. A later error updates the open overlay in place, or opens a new one after a dismissal. Its text is set with textContent and its styles through element.style, so an error message never becomes markup. Because production has no SSE bus or browser overlay, this channel never leaks into --build or --server output.
  • Auto-reconnection: After a dropped connection the client retries five times, one second apart, showing a "Reconnecting" banner, then shows "Dev server offline" and keeps retrying every five seconds, so a tab that stays focused reconnects (and reloads) once the server restarts. Focus or a visibility change reconnects at once, and streams close cleanly on page unload. The banner is a role="status" region (aria-live="polite", aria-atomic="true") that stays in place while its text changes, so each new message is announced.
  • HEAD Handling: Responds to HEAD /bascik-live-reload with headers only and terminates without holding an open stream.
  • Production Guard: Never injected into --build output, returns 404 on --server, and runtime-stripped in server-prod.ts as defense in depth. The injected <script> carries a data-bascik-live-reload attribute, and the production strip removes only elements whose own open tag carries that attribute. It never identifies the script by scanning page content for /bascik-live-reload, so ordinary client scripts, prose, and code samples that mention the SSE path are served unchanged. The strip finds script elements the way a browser parses the page, so markup that only looks like a live-reload script stays as it is, and removing a script never joins the text around it into new markup.

Open-page priority transpilation (partitionByOpenPages)

When multiple pages must be re-transpiled at once (for example, when modifying a shared component used across many pages, editing an inlined stylesheet, or running processAllPages()), compiling every page sequentially before notifying the browser could introduce visible latency on large sites.

Bascik solves this with open-page priority batching (partitionByOpenPages in processing.ts):

  1. Active tab tracking: Each active browser tab connected to GET /bascik-live-reload registers its normalized HTTP route in mem.openPages (derived from the HTTP Referer header).
  2. Queue partitioning: When a batch transpilation begins, the page list is split into openPages (pages currently open in at least one browser tab) and restPages (all other pages).
  3. Immediate reload emission: The dev server transpiles all openPages first, commits them into MemoryStore, and emits the "transpiled" event immediately. Connected browser tabs reload in milliseconds.
  4. Home page next: The home page (/) is the page most often opened next, so it is the first of the remaining pages. Directory indexes such as blog/index.html are not the home page. --build skips this step and keeps the listing order, so build order never depends on dev behavior.
  5. Background completion: The remaining pages are transpiled and cached in file order.

On the main thread the three groups run as consecutive batches. With WorkerPool (pipeline.workers: true), the open pages run first, then the remaining pages are pulled from a queue by lanes (two per worker) that pick the next page when a lane is free, not once up front. A tab that connects after the batch started (for example one reconnecting after a restart, while the first compile is still running) therefore moves its page ahead of every page that has not begun. Two lanes per worker keep each worker busy while the main thread stores the previous result, so the bound costs no measurable throughput. A WorkerPool slot that takes a queued task leaves the idle list at that moment. Submitting a task to a slot that is already busy would replace its pending job, which would never settle.

This prioritization otherwise operates identically on the main thread and across workers. In single-threaded execution, per-page durations approximate synchronous page work by excluding time parked at Bascik's explicit await boundaries. They are not total CPU-time measurements and exclude asynchronous callee continuations that run while a page is paused. The largest excluded cost, the child processes that run the page's build scripts, is printed after the duration as (+<time> in N build scripts[, M cached]), and the pages transpiled summary carries the site-wide ran/cached counts; see When every script runs again. In dev mode, transpiling at least 20 page jobs in 2.0s or longer on a machine with 4 or more CPU cores emits an advisory suggesting pipeline.workers: true. This does not change configuration automatically.

Lifecycle publication scopes preserve compilation priority but defer reload delivery through post. Both main-thread and worker results join their disk writes before post scripts inspect dist/.

Production Server Mode (bascik --server)

When launched with bascik --server, Bascik runs as a production HTTP server (server-prod.ts). Only the flag selects that server: BASCIK_SERVER=1 on a bare bascik still starts the dev server, but with the server mode configuration, because config.ts reads the variable while index.ts dispatches on the parsed command line. Use bascik --server. It is the same server.ts core that dev mode uses; server-prod.ts only adds the boot-time dist/ load, and server-dev.ts only adds watching, live reload, and the boot page.

Boot-Time Loading: Pages in Memory, Assets on Disk

Production mode skips file watchers and live-reload injection, but it does not skip in-memory page storage. server-prod.ts#loadDistIntoMemory walks the built dist/ directory at boot and reads every .html file into the same MemoryStore (mem.ts) that dev mode uses, so page lookups and data-bascik-server execution are served from memory on every request, identical to dev mode.

Only non-HTML static assets, images, fonts, favicons, the webmanifest, and any other file copied verbatim from src/pages/, are read from the dist/ filesystem per request. Component CSS and JavaScript are always inlined into the HTML at build time, so they are already in memory as part of the page buffer; there is no separate in-memory cache for them to miss.

Static representation snapshot integrity

A static response is one selected representation delivered as an immutable owner. The server acquires the file bytes once, then derives the body, Content-Length, and ETag from that exact buffer, so a response never advertises a validator that describes different bytes. It does not separately stat, hash, and re-open the pathname across independent acquisition lifetimes, and it does not stream a second read whose bytes could drift from the hashed ones after an atomic rename or in-place write. Precompressed .br/.gz sidecars are bound to verified raw content identity: a sidecar is served only when its provenance stamp matches the current raw content hash and it decompresses cleanly at representation creation, so stale or corrupt sidecars are never served under the current asset's ETag. A sidecar's provenance is a sibling .br.bmeta or .gz.bmeta JSON file recording {"rawHash": "<quoted content-hash etag>"}; without it a sidecar is treated as unverified and ignored. On-demand compression otherwise follows the bounded asynchronous representation owner (section below).

Delivery is two-tier, decided by file size. getStaticDelivery in caching.ts is the single owner of that decision, and the bound is the constant MAX_BUFFERED_ASSET_BYTES (2 MiB, equal to MAX_CACHED_REPRESENTATION_BYTES so "buffered" and "cacheable" describe the same set of assets). It is not configurable.

  • At or under the bound: buffered immutable representation. One read produces one buffer; the strong content-hash ETag, Content-Length, and body all derive from that buffer. This tier is compressible (Brotli or gzip by negotiation) and retained in the bounded representation cache. It guarantees that validator and body describe the same bytes, and that a 304 means the client already holds exactly those bytes.
  • Above the bound: streamed identity. The file is opened once with fs.promises.open; fstat on that handle supplies Content-Length and the validator, and the body is fd.createReadStream() piped to the transport, so memory per in-flight request is bounded by the stream's high-water mark rather than the file size. The validator is deliberately weak, W/"<size>-<mtimeMs>-<ino>" in base36, because the bytes are never hashed before they are sent and a strong validator would be a claim the server cannot back. Body and validator still describe the same inode and length: the pathname is not re-opened between deciding what to advertise and reading what to send. This tier is never compressed and never cached. HEAD and a matching weak If-None-Match are answered before any read stream exists. A read error before headers is a 500; a read error after headers destroys the transport rather than leaving a truncated body that looks complete. The handle is closed on every path, including client abort.

What the streamed tier does not guarantee: a weak validator cannot distinguish two in-place writes that preserve size, mtime, and inode, so a 304 on this tier means "probably unchanged", per the HTTP definition of weak validators. Immutable release directories (below) make this moot in production. Operational guidance still applies: very large media (video, large archives) is better served from a CDN or object store, but a large asset in dist/ behind bascik --server no longer costs a full copy per request.

Precompressed sidecars require a provenance stamp, and the build can produce them. A .br or .gz file next to an asset is served only when a sibling .bmeta file records the current raw content hash. With http.precompress: true, bascik --build emits <asset>.br, <asset>.br.bmeta, <asset>.gz, and <asset>.gz.bmeta for every compressible static asset it produced whose size is at least COMPRESSION_MIN_BYTES (512 bytes); pages, .bascik/* metadata, already-compressed formats, and files already carrying a sidecar suffix are skipped. The emitter (precompress.ts) runs inside finalizeOwnedArtifacts, reads each asset once, derives the hash, both encodings (Brotli max quality, gzip level 9), and both stamps from that single read, writes each file to a temp sibling and renames it into place, and records every emitted file in the build manifest. The hash written to .bmeta is getContentHashEtag(rawBytes), the same function the server compares against, so a sidecar produced by the build can never be served under an ETag describing different bytes. The option is off by default because it costs two max-quality codec passes per asset at build time and roughly doubles the on-disk footprint of compressible assets.

The .bmeta format is a stable external interface: an external pipeline that produces its own sidecars must write <asset>.br.bmeta / <asset>.gz.bmeta containing {"rawHash": "<quoted SHA-256 hex of the raw bytes>"} (the same string the server sends as the identity ETag, including the surrounding double quotes). Sidecars without a matching stamp are ignored and the server compresses on the fly.

The operational default for production is immutable release directories: dist/ contents are not rewritten in place while being served. This lets the server rely on single-read snapshot consistency. Mutable-dev behavior is explicit and also safe: dev serves the in-memory page buffers produced by the transpiler, and static assets are re-read through the same single-ownership path on every change, so validator and body always agree even when files are written in place during development.

At boot time, server-prod.ts also checks for .bascik/dist/server-scripts.json. A missing sidecar is optional only when no stored page references a server script: a genuinely static release with no server scripts and no placeholders remains valid and is served normally. Only a missing file counts as missing: a regular file where the .bascik/ folder or its output folder belongs is a broken layout, and startup fails naming that file. Windows reports this case as ENOENT rather than ENOTDIR, so before reading ENOENT as "no sidecar" the server checks the path's nearest existing ancestor (fileInPlaceOfFolder in fs-errors.ts). If any page carries a data-bascik-server-id placeholder and the sidecar is absent, startup fails (see readiness validation below). When present, it is parsed and validated before the socket binds; a malformed, schema-incompatible, or stale sidecar aborts startup with an actionable rebuild message. Each entry retains the authored HTML source path alongside the script source and optional external module path. Page HTML templates in dist/ contain inert placeholder script tags (<script type="text/bascik-server" data-bascik-server-id="..."></script>) that reference these entries. On each incoming request, executeServerScripts resolves each placeholder by ID, rewrites relative imports from the authored source directory and import-root (@/) imports from scripts.importRoot, runs the corresponding script with the request context in-process via ScriptRegistry, and injects the returned markup into the response.

What a settled script becomes is decided in one place, settleScriptOutput (script-output-policy.ts), a portable module in the runtime that runServerScriptJob, the Cloudflare Worker's job runner and the Netlify function's all call: it prints the value, strips ANSI escapes, applies a component's output scope, refuses a value that is not markup, and applies scripts.onServerScriptError to a failure. Each host passes a ScriptOutputPolicy naming only what is its own: its log, how it describes a failure (Node maps the stack to the authored file; a function names the owner the build recorded), the error class it throws, and how it remembers a script's first refused value. scriptJobOutput wraps it for a whole invocation, including a script that yields. What a returned value prints is decided by serverScriptOutput (server-script-output.ts), which that function calls, so every host prints the same bytes: a string as it is, an html or raw() value as its markup, an array joined with no separator (nested arrays flattened up to 1000 levels, null, undefined and false items empty), a number, bigint or true as text, and an object with its own toString as that text. A plain object, a Map, a Response, an Error (whatever its toString, since its text may be an internal message), a function, a symbol, or an array holding a Promise is refused with serverScriptOutputErrorMessage, in the server script error at "<path>" frame and naming the script (The server script in src/pages/x.html:12 returned ...). runServerScriptJob then applies it by mode: in development (bascik) it is a failure under scripts.onServerScriptError like a throw (a ServerScriptError under 'error', so a server job is a 500 before commit and a stream job an empty slot with the error logged); in production (bascik --server) it never fails the page: the slot is empty and the message is a console.warn once per script start position for the life of the process, nothing under 'ignore'. The Worker always behaves as production, once per job per isolate; its page jobs carry no owner in the graph, so its message names the script's kind where Node names the file and line. A throw from the author's own toString while printing is the script failing, under the policy.

When the sidecar has page-owned entries, boot also reads .bascik/dist/components.json (readComponentManifest in component-manifest-file.ts, which the server imports instead of the builder in component-manifest.ts, so boot never loads the build pipeline). It is a required runtime artifact for such a release: missing, of another schema, malformed, or naming a component script the sidecar does not stage, it aborts startup before the socket binds, with a message that says to run bascik --build. loadComponentJobs (request-render-node.ts) then builds one ScriptJob per component script, shared by every request. When the store keeps a page with a plan and a manifest, prepareServerScriptPlan (server-scripts.ts) builds the page's portable plan and prepareRenderer (request-render.ts) the renderer's per-page work: the component list, its compiled tag search, the plan's script modes, and under minify.html the page scan. Each is cached on the plan or the manifest, so the first request to a server or stream route does not pay for them. A static release has no sidecar entries and no manifest, and boots exactly as before.

Readiness validation (/_health/ready)

Boot is one production readiness boundary. Before bascik --server advertises readiness, every stored page's precomputed server-script plan must resolve: a stored { error } plan (an unresolvable or stale placeholder ID, a stale stream/server mode marker, or a conflicting directive) proves the release's required runtime artifacts cannot be resolved, so startup fails with an actionable diagnostic rather than binding a socket that would return 200 on /_health/ready and 500 on the page. The check runs regardless of sidecar presence: a placeholder page with no sidecar records an unresolvable plan and fails startup with a message naming the missing sidecar. Liveness (/_health/live) stays a process-up signal and remains distinct from readiness. Only a release with no sidecar and no placeholders skips this check and is always ready at boot. If validation or binding fails, readiness state resets to booting so a later probe never observes a stale ready answer.

Per-request data-bascik-server and data-bascik-stream execution (server-scripts.ts)

During memory store indexing (mem.ts#storePage), Bascik precomputes a serverScriptPlan containing alternating static byte chunks and script segment descriptors with their configured execution mode (server or stream). In production boot, loadSidecar runs before the store loop so sidecar metadata (schema version 3: script mode, the outputScope of scripts inside components, and the component_script_* entries for scripts inside components under the owner components) is registered in memory. A stored { error } plan is surfaced by the readiness boundary before the socket binds, so a required runtime artifact that cannot be resolved never becomes a per-request 500 behind a ready health check.

A page has a plan only if it contains at least one script segment. The store first runs a loose byte pre-filter for the directive names, then the exact scan. A page that merely talks about a directive, such as documentation, a blog post, or an escaped code sample, passes the pre-filter but contains no script, so it gets no plan and is served like any static page: with an ETag, 304 support, and Brotli or gzip. A page with a real script, including a sidecar placeholder, always has a script segment, so it keeps its plan and is sent private, no-store, because its output can differ on every request. An unresolvable placeholder is still stored as an { error } plan, so a broken release still fails the readiness boundary.

When a request arrives:

  1. Plan evaluation and buffered path: If the page plan contains no script segments, or only server scripts, or if the HTTP method is HEAD, the request handler takes the buffered execution path. All server scripts execute concurrently in-process via ScriptRegistry. If any script throws under scripts.onServerScriptError: 'error', execution halts and the server responds with an HTTP 500 error page. When all scripts resolve, the full document is assembled, headers (including Content-Length and ETag) are set, compression is applied if eligible, and the complete buffered response is sent. A client that disconnects before the buffered page is ready (close event) aborts every pending script's signal, and no response is sent.
  2. Two-phase stream execution: If the plan contains at least one stream script segment and the method is not HEAD:
    • Phase 1 (Server script resolution): All server scripts on the page resolve first via ScriptRegistry. If any server script fails under scripts.onServerScriptError: 'error', the response aborts with an HTTP 500 before any byte reaches the network.
    • Phase 2 (Header commit and ordered chunk delivery): Once all server scripts succeed, the server commits response headers (transfer-encoding: chunked on HTTP/1.1 or DATA frames on HTTP/2, cache-control: private, no-store, without Content-Length, ETag, or Brotli Content-Encoding). Static HTML bytes prior to the first stream tag are flushed immediately. stream script jobs are dispatched through a bounded lookahead window (STREAM_LOOKAHEAD = 1): the job the write cursor is waiting on plus the next adjacent stream job may run concurrently, and later jobs start only as the cursor advances. Resulting chunks are written to the response sink in exact source document order. A stream script that is a generator gives the scheduler an async iterable instead of a string: each yielded value is its own chunk, written as it arrives, and the next value is pulled only after that write resolved. A generator dispatched ahead of the write cursor is pulled once (it runs to its first yield) and holds that one chunk until its turn. On the buffered path, and for a server script, the yielded values are joined into one string.
  3. Backpressure and disconnect handling: Chunk writes check the return value of res.write(). If a write returns false, production pauses until the underlying socket emits drain. Because job dispatch is tied to the write cursor, a stalled socket also stops new stream jobs from starting, so unwritten script output held in heap per connection stays bounded by the lookahead window rather than growing with the number of stream tags on the page. If the client disconnects before streaming completes (close event), all pending script jobs are aborted immediately via their passed AbortSignal ({ signal }), and res.end() is never called on a destroyed stream. A generator that is part-way through, or was dispatched ahead and never reached, has its return() called, so its finally blocks run.
  4. Post-commit errors: Because response headers are already committed before stream output begins, a runtime error in a data-bascik-stream script cannot return an HTTP 500. Instead, the error is logged at the configured severity (scripts.onServerScriptError), an empty string is emitted for that slot, and the rest of the document streams to completion. The author's placeholder markup remains in the DOM. A generator that fails part-way keeps the chunks already written and ends its slot there. Its scripts.timeout budget is charged only while a pull is pending (invokeLoadedWithDeadline with chunks: true pauses the deadline between pulls), so the time a slow client takes to accept a chunk is not the script's.
  5. Error reports: Each script failure is reported once. ScriptRegistry returns a failure without logging it, and runServerScriptJob alone decides whether it is reported, from scripts.onServerScriptError, so 'ignore' prints nothing. Under 'error' it throws a ServerScriptError whose message is the whole report: [bascik] server script error at "<path>": and the user's stack, mapped to the line and column in the file the script was written in. The request handler's onError and the stream path print that message alone, never the error's own stack, which would add Bascik's request-handling frames. The production server reads the line and column from the sidecar (sourceLine, sourceColumn), so its reports match the dev server's.

Host-neutral execution core (request-execution.ts)

The scheduler described above is not Node code. pkg/src/lib/request-execution.ts implements buffered composition (composeBufferedResponse), the two-phase streamer with its bounded lookahead (streamComposedResponse), deadline-bounded invocation (invokeWithDeadline, which with chunks: true turns a returned generator into a ScriptChunkSource pulled under the same deadline and signal), the adapter from that source to a job's chunked output (scriptChunkOutput), and API method dispatch (dispatchApiHandler: a named method export, then HEAD derived from GET, then the route's default export) against Web platform primitives only: Uint8Array, ReadableStream, AbortController, Request, and Response. It never imports node:*, the config, the filesystem, or the module registry; serverless-contract.test.ts bundles it for platform: "browser" and fails on any Node resolution error.

Hosts adapt onto it:

  • The Node server (server-scripts.ts, api-runtime.ts) supplies a ScriptJobRunner that resolves each job through ScriptRegistry, a StreamWriter over the backpressure-aware response sink, and a classifyError hook that maps Node transport failures (PayloadTooLargeError to 413, socket resets to 499) before the generic 500. The pure route matcher lives in route-matching.ts; api-routes.ts re-exports it and adds only the filesystem scanner.
  • The Cloudflare adapter (adapters/cloudflare/src/runtime.ts) supplies a runner over the compiled module graph, returns streamComposedResponse(...).toReadableStream() as a Response body before the body completes (the stream is demand-gated, so returning early is required, not optional), and maps env and ctx.waitUntil onto context.platform.

Every handler receives context.platform ({ name: "node" } on the built-in server; { name: "cloudflare", env, waitUntil } in a Worker). It is additive: a handler that never reads it runs unchanged on both.

Request-time component rendering (request-render.ts)

A server or stream script may return component tags. The build records every component as a ScopingTemplate in .bascik/dist/components.json (component-manifest.ts) whenever the reconciled sidecar has page-owned entries: the scoping pipeline run once on a sentinel instance id, with the component's build scripts already run into it and its server and stream scripts staged in the sidecar as component_script_* jobs. A component the template cannot reproduce is recorded with an unsupported reason instead. The manifest is regenerated on every build that has entries, full or targeted, deleted when the last page script goes, and written with sorted keys so two builds are byte-identical. The build starts it while the pages compile, as soon as a page source has a server or stream script element or a compiled page has one, and builds up to eight components at a time; each component's console output is held and printed in component order where a manifest built after the pages would print it (ordered-output.ts). A component build script the page build already ran without reading its page context is answered from that run's cache record.

At request time toExecution (server-scripts.ts) wraps the page's job runner with wrapRunner when the stored page carries a manifest. wrapRunner is host-neutral (no node:*; serverless-contract.test.ts bundles it for the Web) and returns the runner itself when the manifest has nothing to render. Otherwise, for each job output that contains <, it:

  1. Runs the compiler's own expansion loop (expandComponents, component-expansion-core.ts) over the output with only the supported manifest entries, so unsupported and unknown tags pass through as written. Each instance's template is instantiated for a fresh request id (r plus 7 hex digits, disjoint from the build's 8-hex ids) by replaying the recorded name hashes (instantiateTemplate, scoping-instantiate.ts). Props bound with data-bascik-attr-* are injected after scoping, with id references mapped through the instance's scoped ids. Each instance is then minified (when minify.html was on) and base-rewritten.
  2. Replaces each staged script placeholder in an instance with a request-local marker. Only markers the renderer minted are dispatched, so a placeholder or directive script an author returns stays inert text. Nested jobs run through the host's runComponentJob with the runner's AbortSignal and the parent's remaining budget, and their output is rendered again, by default at most 4 levels deep and 64 nested jobs per response (scripts.renderLimits).
  3. Prepends each used component's <style> block, once per response per mode, decided in dispatch order through a per-mode promise chain, so the first instance in document order gets the block whatever order jobs finish in.

Budget, depth, and stage failures throw RequestRenderError, naming the route and the component file. The page job's wrapper catches it and hands it to the host's onRenderError, which applies scripts.onServerScriptError the way the host's runner does for a script that throws, with the same server script error at "<path>" frame (renderFailureMessage) and no stack: under 'error' it throws (Node: a ServerScriptError), so a server job rejects ready (a 500 before commit) and a stream job is logged and absorbed into an empty slot; under 'warn' it logs and the job's slot is empty; under 'ignore' the slot is empty. The whole page job's slot is emptied, never a partial render. A nested job's own failure has already been through the policy in runComponentJob. Nested jobs run under the wrapper's own AbortSignal, linked to the request's: when the render fails, the wrapper aborts it, so sibling nested scripts still running stop and log nothing for a slot already decided. The Cloudflare host does the same with its graph's policy and log. The Node host (request-render-node.ts) supplies a bounded name hasher per manifest (createNameHasher over node:crypto SHA-256, the same algorithm as getAttributeNameHash without its process-wide cache) or identity for names, node:crypto for ids, and, in development, warnOnce. Every render message names a component file by the manifest's filePath, which the build records in the form bascik --check prints (components/...), so the dev server, bascik --server and the Worker name it alike. In development the manifest is built once per compile publication and attached to each StoredPage with a plan, so a request never mixes two versions. runServerScriptJob applies a job's outputScope with applyOutputScope, so markup returned by a script inside a component gets the component's class mapping and element classes whether the component was placed on a page or returned by a script.

Serverless adapter contract and runtime exports

bascik --build --target <name> orchestrates hosting adapter builds through a typed contract (HostingAdapter in @bascik/bascik/adapter). Core exposes two dedicated package exports:

  • @bascik/bascik/runtime: the host-neutral request execution engine (request-execution.ts, web-response.ts, route-matching.ts), the request-time component renderer (request-render.ts, component-expansion-core.ts, scoping-instantiate.ts, output-scope.ts, name-hash.ts), and the return-value rule (server-script-output.ts). It is the same code the built-in Node server runs, bundled with zero Node builtins.
  • @bascik/bascik/adapter: the adapter contract (SiteGraph, AdapterBuildContext, AdapterBuildResult, defineAdapter, and readSiteGraph).

Core reads the finalized dist/ output, sidecar, and component manifest into a SiteGraph (the manifest and the component scripts as SiteGraph.components), stages inline scripts with module specifiers rewritten, resolves the target to an adapter package or local module, and calls adapter.build(context). Core validates that all outputs stay within dist/.bascik/<target>/, writes build-info.json, and removes staging files. --target is rejected with --only because a deployment bundle must describe one consistent release.

Why stream pages skip ETag, Brotli, and content-length

HTTP chunked streaming delivers bytes incrementally as they are produced. Calculating a Content-Length or ETag requires buffering the entire response in memory first, which defeats the purpose of early flushing. Similarly, max-quality Brotli compression (BROTLI_MAX_QUALITY = 11) requires large sliding lookahead buffers that prevent immediate chunk delivery. Consequently, pages with active stream scripts omit Content-Length, ETag, and Brotli Content-Encoding headers and emit Cache-Control: private, no-store.

Caching and performance (http.httpCache)

Production mode enables http.httpCache: true by default:

  • ETag support: Generates strong ETag hashes for static assets and buffered HTML responses (pages without data-bascik-stream scripts), returning 304 Not Modified when the client's if-none-match header matches.
  • Cache-Control headers: Adds Cache-Control: public, max-age=3600 to static assets.
  • Byte ranges: Static assets advertise Accept-Ranges: bytes. A GET with a single Range (bytes=a-b, bytes=a- or bytes=-n, parsed by byte-range.ts) gets 206 Partial Content with Content-Range, served from the buffered representation or as a ranged file stream for a large asset; a range past the end gets 416 with Content-Range: bytes */<size>. A ranged request always gets the identity representation, so the offsets name the file's own bytes. If-Range is honored with a strong ETag (or a date that matches Last-Modified); anything else, including a weak ETag, serves the whole asset with 200. A multi-range request is answered with the whole asset. Pages are not ranged.
  • Max-quality Brotli compression with Gzip fallback: Uses BROTLI_MAX_QUALITY = 11 for static assets and buffered HTML responses for optimal bandwidth savings. Clients that do not advertise br in Accept-Encoding receive Z_BEST_COMPRESSION Gzip instead, with its own "hash-gzip" ETag variant. Streaming responses bypass compression to preserve real-time chunk delivery.

Production rate limiting

Production mode enforces a sliding-window rate limit per IP address (by default 500 requests per 10-second window). Clients exceeding the limit receive 429 Too Many Requests with a Retry-After header naming the configured http.rateLimit.window in whole seconds (10 by default), by which time every request counted against the client has left the window.

  • Sliding Sub-Windows: Uses a ring of 10 sub-buckets per window to smooth boundary bursts and prevent double-budget attacks at window edges.
  • Trust Proxy Support: When http.trustProxy: true is configured, client IP derivation reads the rightmost entry of X-Forwarded-For (the address appended by the immediate trusted proxy), request URL origin reconstruction reads the rightmost entries of X-Forwarded-Proto and X-Forwarded-Host (safely validating syntax and falling back to local transport parameters), and HSTS checks the rightmost X-Forwarded-Proto. When false (default), forwarded headers are strictly ignored to prevent spoofing.
  • Request Origin Authorities: Authority validation uses the WHATWG URL parser after rejecting URI delimiters and invalid ports. Valid bracketed IPv6 addresses, including IPv4-mapped IPv6, and trailing-dot domain names are supported. An invalid forwarded authority falls back to the local Host or HTTP/2 :authority; an invalid local authority falls back to localhost.
  • Bounded Tracking Map & Expiry Lifecycle: The internal IP tracking Map is capped (MAX_TRACKED_IPS = 10_000, or http.rateLimit.maxTrackedIps) and kept in least-recently-seen order: every request deletes and reinserts its identity. When a newcomer arrives at a full table, the limiter first drops expired identities from the old end, then evicts the least recently seen live identity, whose count starts over if it returns. A full table never refuses a request (the bounded-zone behavior of nginx limit_req and CDN limiters), so a flood of fresh addresses can reset other clients' counters but cannot deny them; the work per newcomer is amortized O(1), not a scan. Evicting a live identity logs [bascik] rate limiter table full: N identities, evicting the least recently seen; raise http.rateLimit.maxTrackedIps or rate limit at the CDN through console.warn, at most once per window and only when logging.level allows warn. An identity is the client IP for IPv4 and the /64 prefix for IPv6 (rateLimitKey in rate-limit.ts; an IPv4-mapped address counts as its IPv4 address), so one sender rotating through its own IPv6 prefix occupies one slot. A background sweep timer periodically cleans up stale identities during active server operation and shuts down cleanly during server exit.
  • Configurable: Accepts boolean or { window?: number, max?: number, maxTrackedIps?: number } in bascik.config.ts.
  • SSE Streams: Excluded from page rate limit checks.
  • Development Mode: Rate limiting is inactive during development mode.

Development vs Production Comparison

CapabilityDevelopment (bascik)Production (bascik --server)
Entry Moduletranspile.ts via server-dev.tsserver-prod.ts
Shared Coreserver.tsserver.ts
Page StorageMemoryStore in memory (mem.ts)dist/ HTML loaded into MemoryStore at boot
Brotli QualityBROTLI_MIN_QUALITY = 1BROTLI_MAX_QUALITY = 11
HTTP CachingDisabled (http.httpCache: false)Enabled (http.httpCache: true with ETags & 304s)
Rate LimitingDisabledActive (500 req / 10s per IP)
Live Reload SSEInjected & activeStripped & inactive
File WatchersActive for assets, pages, componentsInactive
Boot PageActive during initial buildDisabled
data-bascik-server ExecutionOn-demand per requestPer request

Shared Security & Reliability

Both development and production server modes share core security and lifecycle mechanisms:

Request routing and path normalization

Every incoming request (req.path) passes through a deterministic normalization sequence before reaching the static asset or in-memory page resolver:

  1. URL Decomposition (? and # stripping): The raw request URI is split on ? and # (req.path.split(/[?#]/)[0]) so query parameters and fragments never alter static asset paths or page lookup keys. For example, /style.css?v=1 or /about#section resolve directly to /style.css and /about.
  2. Percent-Encoding and Control Character Sanitation: Paths are decoded using decodeURIComponent(). Malformed percent-encoding, null bytes (%00), control characters (\r, \n, \t), or paths containing .. traversal patterns immediately yield a 400 Bad Request with content-type: text/plain; charset=utf-8.
  3. Dot-Segment Rejection: After decoding and traversal validation, any path segment beginning with . yields 404 Not Found before static file lookup. This catches literal and encoded paths such as /.env, /.git/config, and /%2Egit/config, and splits segments on \ as well as /, because Windows path.resolve treats a backslash as a separator (/a%5C.env would otherwise open dist\a\.env there). The static branch applies the same rule again to the resolved file path, segment by segment on the platform separator, so the file actually opened is checked whatever spelling reached it. It protects internal output directories such as the dist/.bascik/<target>/ deployment folders. The one exception is RFC 8615 .well-known as the first segment below the site root (below base when it is not /), so /.well-known/security.txt reaches the static resolver. The match is exact and case-sensitive, never applies deeper in the path, and dot segments inside it (/.well-known/.env) are still rejected. The rule is hasHiddenSegment(pathname, base) in route-matching.ts, shared by this guard, static asset copying (asset-filter.ts), adapter upload filtering (isPublicAssetPath), and the Cloudflare Worker.
  4. Base Prefix Stripping: After security guards pass, the normalized base prefix is removed before static assets, live reload, or pages are resolved. Requests outside a non-root base return 404. A path with a non-HTML extension is a static asset only when it does not end in /: /style.css/ names a directory, so it skips the file read and falls through to page lookup, which answers 404 unless a dotted directory page (pages/v1.2/index.html) exists, matching static hosts and the Cloudflare asset layer.
  5. API Route Dispatching: Matching requests are dispatched to in-process WHATWG Request/Response handlers loaded via the ScriptRegistry before the GET/HEAD method guard. Unexported methods, and any method outside ALLOWED_METHODS even when the module exports a function of that name, return 405 with Allow (dispatchApiHandler in request-execution.ts; the early 413 for a declared body over the limit applies only to a method that would be dispatched). So do TRACE, TRACK and CONNECT, which the fetch Request constructor refuses, answered from the loaded module's Allow before a Request is built. The handler's request.url takes its host from the request origin and appends the request-target, so a target starting with // stays in the pathname instead of being read as a scheme-relative host. The matcher (route-matching.ts) receives both the decoded pathname and the raw, undecoded one: exact routes use the decoded path as before, while [param] routes and a [...name] catch-all split the raw path on / and decode each segment once, so an encoded separator can never forge a boundary and a value is never decoded twice. The most specific match wins (static, [param], catch-all) independent of table order, and an unsafe [param] or catch-all capture (an encoded / or \, a dot segment, a control character) answers 400 before any handler loads. The Cloudflare Worker calls the same matchApiRoute with url.pathname as the raw path.
  6. Referer Normalization for SSE Open-Page Tracking: When browser tabs establish live-reload connections through /bascik-live-reload, the server extracts new URL(req.headers.referer).pathname, strips the base, and calls getHttpPath() to track active tabs accurately.
  7. Page Route Resolution Order: For page requests, lookup follows a strict priority chain:
    • Exact literal path match (mem.getPageExact(pathname))
    • Strip .html extension if present
    • Alternate trailing-slash variant (/blog vs /blog/)
    • Fallback to full mem.getPage() lookup (which returns /404 if unmapped)
  8. Access Logging Lifecycle: A request logs once, when the request handler returns (its finally block). For a buffered page or asset that is when the body has been handed to the transport with res.end, so the duration covers routing, lookup and any server scripts but not the time the client takes to receive the bytes; for a streamed asset or a streamed response the handler awaits the stream, so the duration includes the transfer. Static asset requests and page responses log status and elapsed duration; /_health probes and SSE pings are excluded from access logs. A buffered page-script failure logs the error response status, such as 500, rather than the page's initially selected 200. Errors after headers are committed retain the committed status; client disconnects use 499.

Security response headers

Every response includes these security headers, resolved once from security.headers by resolveResponseSecurity in web-response.ts (the builder the Worker shares) and re-resolved only when the config object changes:

HeaderValue
x-content-type-optionsnosniff
x-frame-optionsSAMEORIGIN
referrer-policystrict-origin-when-cross-origin
cross-origin-opener-policysame-origin-allow-popups
cross-origin-resource-policycross-origin

Over HTTPS (a TLS server, a :scheme of https, or x-forwarded-proto: https under http.trustProxy) the server adds strict-transport-security with the value from security.hsts, max-age=31536000 by default. Pages with server or stream scripts get cache-control: private, no-store unless security.privateDynamicPages is false. A path with a dot segment is answered 404 by isPrivatePathname (route-matching.ts) before any lookup; with security.hideDotfiles: false only a first .bascik segment is, and a dotfile name is served as a static file. See security.

Graceful shutdown sequence and health checks

When receiving SIGTERM, SIGINT or SIGHUP (a supervisor or IDE that stops the server with SIGHUP gets the same graceful stop, and pipeline.exec children are stopped rather than orphaned):

  1. Server health state changes to draining, causing /_health readiness checks to immediately return 503 Service Unavailable while /_health/live continues returning 200 OK.
  2. Idle keepalive connections are closed with closeIdleConnections(), and HTTP/2 sessions receive session.close() (GOAWAY) to prevent new streams without resetting active work.
  3. The server stops accepting new TCP connections and drains in-flight requests during http.timeouts.drain (default 5000 ms). Live-reload SSE streams are closed by the SSE manager's registered shutdown handler at signal time, so an open browser tab never holds the process to the drain deadline.
  4. Registered shutdown handlers (watchers, SSE manager, rate limiter) and the exit stoppers (page worker pools, pipeline.exec child processes) are executed concurrently and awaited; errors are caught and recorded. A page worker pool stops each worker once it has loaded by posting it a stop message and waiting for the thread to end itself (it stops its nested syntax checker, closes its port and lets its event loop drain), terminating only a worker that has not exited after 3 s: an external worker.terminate() that lands while V8 is allocating code can abort Node 24 (Check failed: jit_page.has_value()). The exit stoppers wait at most 13 s in all. An exec child and every process it started get SIGTERM, then SIGKILL after 200 ms; a started process that outlived its script still gets it, and the stop waits for that deadline. Before any SIGKILL, the descendants listed at SIGTERM are matched by start time in a fresh ps snapshot, so a reused process ID is not signaled. Where ps has no start time column (the BusyBox ps -o pid,ppid fallback), a descendant is confirmed only when its parent chain up to the exec child is unchanged and the child itself has not exited; any other is left alone and named in a warning, printed once per script. Each ps snapshot blocks the main thread (tens of milliseconds on a busy machine), so a stop shares them: one before SIGTERM for every child's tree and the pending leftovers, and one at the deadline for every child's re-check. On Windows taskkill /pid <pid> /T /F ends the tree instead, run only while the exec child has not exited (after that its pid may name another tree); a taskkill that cannot run is a single warning, and the child itself is still stopped. A timed-out script's leftovers waiting for their SIGKILL get it at once when Bascik exits. The exit stoppers also run on exits that skip the drain (exitAfterStopping: a failed build or dev boot, a dev bind failure, the crash net), so no exit leaves an exec script running.
  5. Once all in-flight requests finish and shutdown handlers settle, the process exits cleanly with code 0. If the combined barrier exceeds the drain deadline, remaining active sockets and sessions are forcibly destroyed and the process exits with code 1.

Port conflict policy

Under bascik --server, binding to an occupied port (EADDRINUSE) is a fatal error that exits immediately to prevent serving from an unexpected port. Under development mode (bascik), port conflicts increment automatically up to 20 attempts. The hostname comes from --host, BASCIK_SERVER_HOST or http.hostname, checked by server-address.ts (a DNS name, an IPv4 address, or a bare IPv6 address with an optional %zone), and the URL the server prints and returns brackets an IPv6 host (formatServerOrigin: http://[::1]:8080, a zone written %25).

With the hostname localhost (the default) the server listens on both loopbacks. listen(port, "localhost") alone binds only the first address the resolver returns, ::1 on macOS with Node 24, which refused http://127.0.0.1:<port> and let another program already on 127.0.0.1:<port> go unnoticed. startServerInstance first binds the other loopback family (from the same dns.lookup("localhost") that listen uses) with a small net.Server that hands each socket to the real server through its connection event, which the HTTP/1.1, HTTPS and HTTP/2 servers all accept; then it binds the server itself. A port held by another program on either address is a port conflict (the next port in development, a fatal error under --server). A host without the other address family is served on the one it has. The server's close() stops both listeners and calls back once the connections of both have ended, so the drain covers either family. An explicit 127.0.0.1, ::1 or other hostname binds exactly that address.

Path traversal protection

Static asset requests are normalized and validated to ensure the resolved path remains strictly within the dist/ directory. Requests attempting path traversal via /../ receive an immediate 400 Bad Request response before file I/O occurs. The decision is isSameOrInside in path-containment.ts; right before the stat, the handler repeats it as a startsWith on the resolved path against the output folder with one trailing separator (folderPrefix), the shape CodeQL's path-injection query recognizes, and a unit test pins that both give the same answer. A lookup whose path names no file answers 404: the file does not exist (ENOENT), a component of the path is a regular file (ENOTDIR, as in /robots.txt/x.png), or a name is over the filesystem limit (ENAMETOOLONG). Other filesystem errors (EACCES, EIO) are server faults and answer 500.

Content-Hash ETags and Caching Layer

Static assets and dynamic pages use deterministic SHA-256 content hashes for ETags rather than fragile timestamp-based or mtime-based ETags. Strong validators are derived from the exact bytes of the selected representation, so the advertised ETag and Content-Length always describe the delivered body. Distinct ETags are emitted for compressed representations ("hash-br", "hash-gzip"), so a 304 is only returned when the client's cached representation matches the encoding it would receive now. The in-memory STATIC_CACHE_METADATA cache keys ETags per file path and invalidates on file metadata change.

Compression negotiation follows RFC 9110 Section 12.5.3, parsing quality weights (q), wildcards (*), and explicit exclusions (q=0) to select between Brotli (br), Gzip (gzip), and uncompressed (identity) representations. Static assets respect a size threshold and skip already-compressed formats (images, videos, WOFF2); assets above MAX_BUFFERED_ASSET_BYTES are streamed as identity with a weak validator and are never compressed (see "Static representation snapshot integrity" above). On-demand static compression uses a shared single-flight representation owner with bounded in-memory caching, so concurrent requests for the same un-precompressed asset share one asynchronous compression task without blocking the event loop or duplicating work. In-memory representations are bounded per item and by cache size, and are invalidated immediately when underlying file metadata changes. Precompressed .br/.gz sidecars are served only when justified by the representation owner: their provenance must match the raw content's current hash and they must decompress correctly at creation, which prevents stale or corrupt bytes from being labeled as the current content. Conditional requests (If-None-Match) support comma-separated validator lists and weak comparison according to RFC 9110 Section 13.1.2 and RFC 9111. 304 Not Modified responses preserve Vary and Cache-Control headers for downstream proxy compliance.

Crash Net & Stream Error Handling

Both HTTP/1.1 and HTTP/2 adapters register stream error handlers that identify client disconnects and network resets via isNetworkResetError. Errors such as ECONNRESET, EPIPE, ECANCELED, ERR_HTTP2_STREAM_CANCEL, ERR_HTTP2_INVALID_STREAM, ERR_HTTP2_INVALID_SESSION, ERR_STREAM_WRITE_AFTER_END, ERR_STREAM_DESTROYED, and ERR_STREAM_ALREADY_FINISHED are filtered out so client disconnects do not trigger spurious error logs or unhandled stream errors. On HTTP/2, an ERR_HTTP2_STREAM_ERROR after the response has ended is the peer resetting a finished stream (curl does it when it is still sending an oversize body after its 413) and is not logged either.

Process-level handlers for unhandledRejection and uncaughtException log full error context and exit with a non-zero code (1) to allow external process supervisors (systemd, Docker container restart policies) to restart the process cleanly.

API response stream and disconnect ownership

API route execution (server-api.ts) enforces strict backpressure and lifecycle ownership across WHATWG Response streams and underlying network sockets:

  • Socket capacity gates consumption: Streamed response bodies are consumed chunk-by-chunk using a response sink. When res.write() returns false, reading from the stream pauses until the underlying transport emits drain. Socket backpressure governs chunk consumption rather than buffering unbounded chunks in memory.
  • Request-lifetime abort ownership: Each dispatch creates a request-lifetime AbortController connected to transport close events (close). If a client disconnects while a handler or stream read is pending, the controller immediately aborts. Disconnect propagates into the handler's signal option and cancels active WHATWG body readers.
  • Independent settlement and resource cleanup: Reader cancellation and handler settlement resolve independently so cleanup never hangs on noncooperative user code. On clean completion, stream error, or client disconnect, reader locks and drain/close listeners are released exactly once.
  • Distinct producer failure vs. network cancellation: If a producer stream fails before headers are committed, the server sends a 500 error response. If a producer stream fails after headers have already been committed, the transport is destroyed immediately as truncated rather than ended as a successful complete response, preventing silent corruption and never emitting duplicate headers.
  • Deadline status: http.apiTimeout starts before the handler runs, so a handler that reads its body spends the upload time against it. When the deadline fires while the handler has started reading a body the client has not finished sending (isBodyPending: the web Request body is in use and the transport stream has not ended), the answer is 408 Request Timeout with Connection: close and a log line that names the slow upload; otherwise it is 504 Gateway Timeout for a handler that ran too long.

In-Process Script Module Registry

Dynamic server-side execution (data-bascik-server scripts and API routes) uses an in-process module registry (pkg/src/lib/script-registry.ts) powered by native dynamic import().

Rationale: In-Process vs. Child Process vs. Worker Threads

  • Rejected: Child process per request. Spawning a fresh Node interpreter plus disk I/O per request per script block incurs 30 to 80 ms of overhead, eliminates caching, and risks fork-bomb resource exhaustion under concurrent load.
  • Rejected: Worker threads pool. A worker pool requires structured-clone serialization of every request and payload, introduces pool lifecycle overhead, and complicates response streaming without solving event-loop starvation within workers.
  • Adopted: In-process module cache. Dynamic import() leverages Node's native module cache and V8 optimizations, executing request handlers with sub-millisecond dispatch overhead.

Module Identity

Every specifier the registry receives is canonicalized to one identity key before loading:

  • A filesystem path (absolute or relative to the project root) is resolved, realpath'd when the file exists, and converted with pathToFileURL, which percent-encodes spaces, #, %, and non-ASCII characters correctly.
  • A file: URL is parsed as a URL, never re-encoded as a path, and its filesystem path is realpath'd the same way. An authored query string or fragment is part of the identity on purpose: Node treats mod.ts?variant=a and mod.ts?variant=b as distinct modules, and the registry preserves that distinction.
  • Inline source uses a data: URL. Its load state belongs to the page's script job, through a weak owner, rather than a permanent registry entry for each historical source. In development, the URL also reflects the generations of its literal file imports. Node cannot resolve a package name or a package imports specifier (#lib/format.ts) from a data: parent, so a resolve hook (data-url-resolve-hook.ts) gives those two kinds the project root as their parent; relative paths and URLs are already rewritten or absolute.

The identity key is the URL without the framework's own generation marker, so a path, its file: URL, a symlinked spelling, and a relative form of the same file all share one module instance. The realpath rule matters because Node's resolver reports realpaths (on macOS, for example, a file under /tmp resolves to /private/tmp), and the development module graph below is keyed by what the resolver reports.

Mode Ownership

The process-wide registry decides its mode once, at construction, from the frozen configuration: bascik --build and bascik --server are production, everything else is development. config.ts imports only the user config, environment, and CLI parser, so reading it from the registry creates no import cycle, and because the mode is fixed there is no mutable global to reconfigure or race against. Tests construct their own ScriptRegistry instances with an explicit mode.

Caching and Invalidation

  • Production (bascik --server): Modules load once on first request and the loaded identity is retained for the lifetime of the process. invalidate() is a deliberate no-op: editing a source file under a running production server changes nothing until the next deploy.
  • Development (bascik): Generations live in the module graph described under Dependency Generations below; the registry and the resolve hook share that one owner. invalidate() advances the generation of the identity and of every module that transitively imports it, and unpublishes each advanced entry; the next load imports the entry under ?bascik-gen=N, which is a new module identity for Node's ESM loader. Stack traces from any generation remap to the authored source. A generation exists only for identities the registry has loaded or attempted, or that the hook has resolved, so watcher events for unrelated files allocate nothing.
  • What invalidation does not do: It never evicts anything from Node's module cache. Clearing the registry's own maps does not reclaim a loaded module; a previous generation stays in memory until the process exits. A request that already holds an entry finishes on that generation, and a load that completes after a newer invalidation is handed to its caller but never published, so no later request observes a superseded module.
  • Error Containment: A module that throws during load is never published. Node caches the failed evaluation under that URL, so in development the attempt is recorded and the fixed file is imported under a fresh generation; in production the next request retries the same identity.

Watcher Ownership of Runtime Modules

Request-time modules (API routes, src= server scripts, and the helpers they import at any depth) are imported by the runtime registry, not by the build, so the page dependency graph knows nothing about them. Inline scripts consult the generations of their literal file imports on invocation. The watchers advance runtime identities directly:

  • The API route watcher calls apiRouteRegistry.invalidateFile, which invalidates the module identity and rescans the route table before api-route-changed is emitted. It is installed in dev whether or not directory.api exists at start (chokidar reports a watched path that appears later), so a folder created while the server runs registers its routes on the first file added.
  • The components watcher reports .html, .css, .js, .mjs, .ts, and .mts files under every component root (COMPONENT_FILE_RE in components.ts) and advances the runtime identity of each changed path before deciding which pages to rebuild.
  • The import-root watcher applies no extension filter: it passes every added, changed, or removed file under scripts.importRoot to the registry, except files under directory.pages or a component root (their own watchers own them) and stylesheets inlined through assets.inlineStyles. Only an identity the registry or the resolve hook has already seen advances, so an edit to a file no runtime module loaded allocates nothing.
  • The pages asset watcher treats .ts, .mts, .cts, .mjs, and .cjs files under directory.pages as script sources (excluding .d.ts declarations and .test.*/.spec.* files; isPageScriptSource in watch.ts). One that is not a published static asset advances its runtime identity and recompiles the pages that read it. A .js file there is a static asset: it is copied to the output, not invalidated. The pages compile watcher observes only .html files.
  • When the changed file is a helper, the module graph advances every entry that imports it, and the log line names them: module invalidated: src/lib/helper.ts (reloads src/lib/src-script.ts).

Watcher coverage boundary: the watchers observe scripts.importRoot, directory.components, and directory.api. A helper imported from outside those roots (for example a sibling directory of the import root, or a path reached through ..) is tracked by the module graph if it lies under the project root, but no watcher reports its edits, so the served output does not update until a watched member of its import chain is edited or the dev server restarts.

Dependency Generations

A generation query on an entry module gives Node a new entry identity, but the import "./helper.ts" specifier inside it is unchanged text: Node resolves it to the same file: URL already in its module map and reuses the evaluated helper. The registry cannot reach dependencies by rewriting the entry URL alone, so development installs a resolve hook that owns generations for the whole import graph (module-graph.ts).

  • The hook. startDevServer calls installModuleGraphHook before the port is bound. It registers a synchronous, in-thread node:module registerHooks({ resolve }) hook exactly once per process; a second call returns the existing handle. The function is a no-op outside development: bascik --server and bascik --build never install it, and server-prod.ts does not reference the module. ?bascik-gen therefore never appears in production URLs or logs.
  • Edges. On every resolution the hook calls the default resolver first, then records child -> Set<parent> from context.parentURL for resolved file: URLs in scope. Keys are the generation-stripped resolved href, which is a realpath because that is what Node's resolver returns; watcher paths are realpath'd through the same identity rule before they are looked up.
  • Generation URLs. When a child's generation is above 0 the hook returns the resolved URL with ?bascik-gen=<generation> appended (authored query parameters are preserved). A generation-0 child is returned untouched, so first loads have clean URLs and stack traces. A URL that already carries the marker is left alone: the registry imports an entry under the exact generation it decided on, and an in-flight request finishes on the generation it started with.
  • Invalidation. invalidateModule(key) advances the key's generation and, transitively, every recorded parent's, with a visited set guarding import cycles. It returns the advanced keys and the registry unpublishes each of them. The next request re-imports the entry under its new generation; Node resolves ./helper.ts from it, the hook hands back the helper's new generation URL, and the edited helper is evaluated fresh. Only invalidated generations change: every other module keeps its identity and its module-level state.
  • Scope. Only resolved file: URLs whose realpath is under the project root (or scripts.importRoot, when it lies outside the project) and not under any node_modules segment are tracked. Bare package specifiers, node: builtins, data: URLs, and files outside every tracked root pass through unchanged and are never recorded. A key the hook has never seen allocates nothing, so a watcher event for an unrelated file does not grow the graph.
  • Inline scripts. Inline server and stream jobs include the generations of literal file imports in their development module identity. An edit to a tracked transitive helper advances its direct importer's generation, so the next invocation loads a fresh inline module even when the inline body is unchanged or reverted. Unrelated helper edits do not reset that module's singleton state. The graph does not retain data: parent edges. Computed import expressions cannot be identified by this literal-import analysis and do not gain the same inline reload guarantee. Watcher coverage and tracked-root limits still apply.
  • What still does not reload. Bare packages (import x from "pkg") keep their identity until restart. Files outside the tracked roots are not tracked. Node's module map is never evicted: every previous generation, including its module-level state (connection pools, memoized caches, timers it started), stays alive until the process exits, so a long dev session that edits a stateful module many times accumulates those instances. A reloaded helper's import.meta.url carries the ?bascik-gen=N marker in development, so code that derives paths from it should strip the query (or use import.meta.dirname / import.meta.filename). Build-time scripts (data-bascik-build) are unaffected by all of this; they run in a child process that starts with an empty module map.

Module Lifetime

Request handlers run in-process. Node retains evaluated ESM identities, including earlier development generations, until the process exits. Clearing framework caches does not evict Node's loader state. Memory is therefore not bounded by the current page count during unlimited request-module edits.

Inline load promises are cached under weak script-job owners. Replacing a page does not keep its old inline sources in a global history map. Accepted requests retain their existing plan and can finish suspended handlers or start delayed stream jobs after a replacement; those old jobs cannot publish loads into the current page or the global registry. File and API modules retain their existing registry cache behavior.

Module-level state, timers, and resources belong to the authored module. Keep request-specific data in handler arguments and release resources explicitly. Matching source bytes alone do not establish that an earlier evaluated module is safe to reuse: its linked dependencies and singleton state may differ.

Production uses stable module identities and immutable releases. Bascik does not automatically restart the process or migrate request handlers into workers to reclaim development generations.

Concurrency and State Isolation

Because modules run in-process, handlers receive request data via an explicit context argument rather than process-global state like process.env. Concurrently running requests execute asynchronously without leaking per-request context.

Timeout, Cancellation, and Limitations

  • Cancellation and Deadline Ordering: Invocations accept an optional upstream AbortSignal and a configurable timeout deadline. Cancellation is observed at each execution boundary: before module load, during deferred module loading, and before handler invocation. If an upstream signal is already aborted or the deadline expires before a handler starts, the handler is never called.
  • Single Settlement State: Each invocation settles exactly once across success, load failure, handler failure, upstream abort, or deadline timeout. Once settled, subsequent timer firings, late rejections, or late handler resolutions cannot mutate the result or trigger unhandled rejections.
  • A Fired Deadline Wins: Once the deadline fires or the upstream aborts, that is the outcome, even when the handler settles afterwards, including synchronously inside its own abort listener. createAbortGate in request-execution.ts decides this by microtask order: its abort listener is registered before the handler can add one and queues the abort decision first, so a settlement that happened before the abort queued its reaction earlier and keeps its value, while one made during or after the abort loses. invokeWithDeadline, ScriptRegistry.invoke, executeApiRoute and the Cloudflare Worker all settle through it, so every host classifies the same handler the same way.
  • Deadline Boundary: The invocation deadline timer starts before module loading begins, ensuring that sluggish module imports or cold-start disk I/O cannot exceed the configured time budget. While native import() cannot be preempted synchronously mid-flight, a deadline expired during import aborts invocation immediately upon import completion and prevents handler execution.
  • Upstream Cancellation Independence: Upstream transport cancellation (client disconnect) operates independently of whether a deadline timeout is configured. In API routes, client disconnect resolves cleanly to a 499 Client Closed Request status rather than being treated as a handler defect or unhandled error.
  • Cleanup: Timeout handles, internal race listeners, and upstream abort listeners are always removed on every completion path (success, failure, abort, or deadline).
  • Synchronous CPU Limitation: In Node's single-threaded event loop, synchronous blocking loops (such as while(true)) cannot be forcibly preempted by an in-process timer. Authors must structure long-running tasks asynchronously.

For the full cross-subsystem time model, ownership boundaries, and deterministic testing strategy, see Time Boundaries.

Graceful shutdown

The server registers signal handlers for SIGTERM, SIGINT and SIGHUP (the --target dev proxy handles SIGHUP too). Upon receiving a signal, it marks readiness as draining, stops accepting new connections, closes idle keepalive sockets, initiates graceful close on HTTP/2 sessions, and awaits registered cleanup handlers for resources such as watchers, exec children, and the SSE live-reload manager. Live-reload SSE streams are terminated immediately at signal time so open browser tabs do not delay shutdown. If all requests drain and cleanup succeeds before http.timeouts.drain (default 5000 ms), the process exits with code 0. If the deadline expires, Bascik force-destroys remaining sockets and sessions and exits with code 1.

E2E Server Testing

Server behavior is validated through Playwright E2E suites across five environment configurations (the pkg/e2e/ configs, plus the adapter's own):

  • playwright.dev.config.ts: Dev server (bascik) live reload, watchers, and boot page.
  • playwright.server.config.ts: Production server (bascik --server) over HTTP/1.1.
  • playwright.server-http2.config.ts: Production server over encrypted HTTP/2 (HTTPS).
  • playwright.config.ts: Static build output serving.
  • adapters/cloudflare/e2e/playwright.config.ts: The emitted Cloudflare Pages bundle running in local workerd (Miniflare) with the asset layer in front, including a paint-order test with JavaScript disabled.

The exec lifecycle has its own lanes (playwright.dev-exec.config.ts, playwright.dev-exec-gated.config.ts, playwright.exec-build.config.ts, playwright.exec-policy.config.ts).

Request-level parity between the Node server and the Worker is covered by adapters/cloudflare/src/serverless-parity.integration.test.ts, which serves one build both ways and compares pages, streams, APIs, methods, cookies, errors, and source leakage. Deliberate platform differences are enumerated in that test rather than excluded silently.