Internals
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:
[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] server.ts: Central server orchestrator. Defines the unifiedcreateRequestHandler()routing pipeline,startServerInstance()port binder, rate limiter, security header handler, and top-levelstartServer()dispatcher.http.ts: Plaintext HTTP/1.1 server (node:http). Wrapshttp.IncomingMessageandhttp.ServerResponseinto Bascik's request context.http2.ts: Opt-in encrypted HTTP/2 server (node:http2). WrapsServerHttp2Streaminto Bascik's request context.pki.ts: Generates TLS certificates whenhttp.tls.enabled: trueis 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:
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:
- Run a complete production compilation and adapter build, rather than a selective in-memory page update.
- 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.
- Expose a local HTTP proxy at
http://127.0.0.1:8787for Cloudflare orhttp://127.0.0.1:8888for Netlify, with--portand--hostoverrides. - 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) toStoredPageobjects containing raw HTML buffers, pre-compressed Brotli and Gzip buffers, and component usage lists.getPageExactperformsO(1)exact lookups and handles trailing-slash path resolution (/blogvs/blog/) directly without redundant Map queries.#components: Inverted index mapping each component name to theSet<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 throughcomponentsImportingStylesheetincomponents.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 (processPageBatchandprocessAllPages) so visible tabs refresh immediately without waiting for background pages. The boot page opens its/bascik-live-reload?boot=1stream with the requested page as theReferer, 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 fromsrc/pages/404.html./500: Rendered fromsrc/pages/500.html. When an unhandled error occurs during request processing,onErrorserves/500with 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:
- Static assets watcher: Copies non-HTML files in
pages/todist/onaddorchange, deletes them onunlink, and triggers a live-reload event. - Page HTML watcher: Listens for
.htmlfile changes inpages/. Triggers full or single-page transpilation and updatesMemoryStore. Itsreadyfires 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 whenboot-donefires. - Component watcher: Listens for changes in every configured
directory.componentsroot. This is the only watcher withfollowSymlinks: 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 asfaq-list/faqs.ts, names no component, so only its importers link it to a page. - Import-root watcher: Listens under
scripts.importRootand advances request-time module identities so an editedsrc=server script reloads on the next request. It does not compile pages.directory.pagesand everydirectory.componentsroot 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 trackslastGenerationand 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'sthreadIdtimes 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[].watchselects matching scripts for the source-owned cycle described above.transpile.tsstarts parallel tasks without awaiting them in dev;startExecDevobserves each outcome.exec-publication.tsmaps 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 anAsyncLocalStoragescope), 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.tsownspipeline.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\ncomments 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 the200event-stream headers, so a connection over the cap gets503 Too Many SSE Connectionsand 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
SseManagerowns exactly onebuild-errorsubscription 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>withrole="alertdialog", opened withshowModal(): 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 withtextContentand its styles throughelement.style, so an error message never becomes markup. Because production has no SSE bus or browser overlay, this channel never leaks into--buildor--serveroutput. - 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-reloadwith headers only and terminates without holding an open stream. - Production Guard: Never injected into
--buildoutput, returns404on--server, and runtime-stripped inserver-prod.tsas defense in depth. The injected<script>carries adata-bascik-live-reloadattribute, 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):
- Active tab tracking: Each active browser tab connected to
GET /bascik-live-reloadregisters its normalized HTTP route inmem.openPages(derived from the HTTPRefererheader). - Queue partitioning: When a batch transpilation begins, the page list is split into
openPages(pages currently open in at least one browser tab) andrestPages(all other pages). - Immediate reload emission: The dev server transpiles all
openPagesfirst, commits them intoMemoryStore, and emits the"transpiled"event immediately. Connected browser tabs reload in milliseconds. - 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 asblog/index.htmlare not the home page.--buildskips this step and keeps the listing order, so build order never depends on dev behavior. - 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 a304means the client already holds exactly those bytes. - Above the bound: streamed identity. The file is opened once with
fs.promises.open;fstaton that handle suppliesContent-Lengthand the validator, and the body isfd.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.HEADand a matching weakIf-None-Matchare answered before any read stream exists. A read error before headers is a500; 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:
- Plan evaluation and buffered path: If the page plan contains no script segments, or only
serverscripts, or if the HTTP method isHEAD, the request handler takes the buffered execution path. Allserverscripts execute concurrently in-process viaScriptRegistry. If any script throws underscripts.onServerScriptError: 'error', execution halts and the server responds with an HTTP 500 error page. When all scripts resolve, the full document is assembled, headers (includingContent-LengthandETag) are set, compression is applied if eligible, and the complete buffered response is sent. A client that disconnects before the buffered page is ready (closeevent) aborts every pending script'ssignal, and no response is sent. - Two-phase stream execution: If the plan contains at least one
streamscript segment and the method is notHEAD:- Phase 1 (Server script resolution): All
serverscripts on the page resolve first viaScriptRegistry. If anyserverscript fails underscripts.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
serverscripts succeed, the server commits response headers (transfer-encoding: chunkedon HTTP/1.1 or DATA frames on HTTP/2,cache-control: private, no-store, withoutContent-Length,ETag, or BrotliContent-Encoding). Static HTML bytes prior to the first stream tag are flushed immediately.streamscript 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. Astreamscript 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 firstyield) and holds that one chunk until its turn. On the buffered path, and for aserverscript, the yielded values are joined into one string.
- Phase 1 (Server script resolution): All
- Backpressure and disconnect handling: Chunk writes check the return value of
res.write(). If a write returnsfalse, production pauses until the underlying socket emitsdrain. Because job dispatch is tied to the write cursor, a stalled socket also stops newstreamjobs 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 (closeevent), all pending script jobs are aborted immediately via their passedAbortSignal({ signal }), andres.end()is never called on a destroyed stream. A generator that is part-way through, or was dispatched ahead and never reached, has itsreturn()called, so itsfinallyblocks run. - Post-commit errors: Because response headers are already committed before
streamoutput begins, a runtime error in adata-bascik-streamscript 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. Itsscripts.timeoutbudget is charged only while a pull is pending (invokeLoadedWithDeadlinewithchunks: truepauses the deadline between pulls), so the time a slow client takes to accept a chunk is not the script's. - Error reports: Each script failure is reported once.
ScriptRegistryreturns a failure without logging it, andrunServerScriptJobalone decides whether it is reported, fromscripts.onServerScriptError, so'ignore'prints nothing. Under'error'it throws aServerScriptErrorwhose 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'sonErrorand 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 aScriptJobRunnerthat resolves each job throughScriptRegistry, aStreamWriterover the backpressure-aware response sink, and aclassifyErrorhook that maps Node transport failures (PayloadTooLargeErrorto 413, socket resets to 499) before the generic 500. The pure route matcher lives inroute-matching.ts;api-routes.tsre-exports it and adds only the filesystem scanner. - The Cloudflare adapter (
adapters/cloudflare/src/runtime.ts) supplies a runner over the compiled module graph, returnsstreamComposedResponse(...).toReadableStream()as aResponsebody before the body completes (the stream is demand-gated, so returning early is required, not optional), and mapsenvandctx.waitUntilontocontext.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:
- 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 (rplus 7 hex digits, disjoint from the build's 8-hex ids) by replaying the recorded name hashes (instantiateTemplate,scoping-instantiate.ts). Props bound withdata-bascik-attr-*are injected after scoping, with id references mapped through the instance's scoped ids. Each instance is then minified (whenminify.htmlwas on) and base-rewritten. - 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
runComponentJobwith the runner'sAbortSignaland 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). - 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, andreadSiteGraph).
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-streamscripts), returning304 Not Modifiedwhen the client'sif-none-matchheader matches. - Cache-Control headers: Adds
Cache-Control: public, max-age=3600to static assets. - Byte ranges: Static assets advertise
Accept-Ranges: bytes. AGETwith a singleRange(bytes=a-b,bytes=a-orbytes=-n, parsed bybyte-range.ts) gets206 Partial ContentwithContent-Range, served from the buffered representation or as a ranged file stream for a large asset; a range past the end gets416withContent-Range: bytes */<size>. A ranged request always gets the identity representation, so the offsets name the file's own bytes.If-Rangeis honored with a strong ETag (or a date that matchesLast-Modified); anything else, including a weak ETag, serves the whole asset with200. 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 = 11for static assets and buffered HTML responses for optimal bandwidth savings. Clients that do not advertisebrinAccept-EncodingreceiveZ_BEST_COMPRESSIONGzip 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: trueis configured, client IP derivation reads the rightmost entry ofX-Forwarded-For(the address appended by the immediate trusted proxy), request URL origin reconstruction reads the rightmost entries ofX-Forwarded-ProtoandX-Forwarded-Host(safely validating syntax and falling back to local transport parameters), and HSTS checks the rightmostX-Forwarded-Proto. Whenfalse(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
Hostor HTTP/2:authority; an invalid local authority falls back tolocalhost. - Bounded Tracking Map & Expiry Lifecycle: The internal IP tracking Map is capped (
MAX_TRACKED_IPS = 10_000, orhttp.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 nginxlimit_reqand 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 CDNthroughconsole.warn, at most once per window and only whenlogging.levelallowswarn. An identity is the client IP for IPv4 and the/64prefix for IPv6 (rateLimitKeyinrate-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
booleanor{ window?: number, max?: number, maxTrackedIps?: number }inbascik.config.ts. - SSE Streams: Excluded from page rate limit checks.
- Development Mode: Rate limiting is inactive during development mode.
Development vs Production Comparison
| Capability | Development (bascik) | Production (bascik --server) |
|---|---|---|
| Entry Module | transpile.ts via server-dev.ts | server-prod.ts |
| Shared Core | server.ts | server.ts |
| Page Storage | MemoryStore in memory (mem.ts) | dist/ HTML loaded into MemoryStore at boot |
| Brotli Quality | BROTLI_MIN_QUALITY = 1 | BROTLI_MAX_QUALITY = 11 |
| HTTP Caching | Disabled (http.httpCache: false) | Enabled (http.httpCache: true with ETags & 304s) |
| Rate Limiting | Disabled | Active (500 req / 10s per IP) |
| Live Reload SSE | Injected & active | Stripped & inactive |
| File Watchers | Active for assets, pages, components | Inactive |
| Boot Page | Active during initial build | Disabled |
data-bascik-server Execution | On-demand per request | Per 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:
- 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=1or/about#sectionresolve directly to/style.cssand/about. - 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 a400 Bad Requestwithcontent-type: text/plain; charset=utf-8. - Dot-Segment Rejection: After decoding and traversal validation, any path segment beginning with
.yields404 Not Foundbefore 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 Windowspath.resolvetreats a backslash as a separator (/a%5C.envwould otherwise opendist\a\.envthere). 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 thedist/.bascik/<target>/deployment folders. The one exception is RFC 8615.well-knownas the first segment below the site root (belowbasewhen it is not/), so/.well-known/security.txtreaches 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 ishasHiddenSegment(pathname, base)inroute-matching.ts, shared by this guard, static asset copying (asset-filter.ts), adapter upload filtering (isPublicAssetPath), and the Cloudflare Worker. - Base Prefix Stripping: After security guards pass, the normalized
baseprefix is removed before static assets, live reload, or pages are resolved. Requests outside a non-root base return404. 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 answers404unless a dotted directory page (pages/v1.2/index.html) exists, matching static hosts and the Cloudflare asset layer. - API Route Dispatching: Matching requests are dispatched to in-process WHATWG
Request/Responsehandlers loaded via theScriptRegistrybefore the GET/HEAD method guard. Unexported methods, and any method outsideALLOWED_METHODSeven when the module exports a function of that name, return405withAllow(dispatchApiHandlerinrequest-execution.ts; the early413for a declared body over the limit applies only to a method that would be dispatched). So doTRACE,TRACKandCONNECT, which the fetchRequestconstructor refuses, answered from the loaded module'sAllowbefore aRequestis built. The handler'srequest.urltakes 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) answers400before any handler loads. The Cloudflare Worker calls the samematchApiRoutewithurl.pathnameas the raw path. - Referer Normalization for SSE Open-Page Tracking: When browser tabs establish live-reload connections through
/bascik-live-reload, the server extractsnew URL(req.headers.referer).pathname, strips the base, and callsgetHttpPath()to track active tabs accurately. - Page Route Resolution Order: For page requests, lookup follows a strict priority chain:
- Exact literal path match (
mem.getPageExact(pathname)) - Strip
.htmlextension if present - Alternate trailing-slash variant (
/blogvs/blog/) - Fallback to full
mem.getPage()lookup (which returns/404if unmapped)
- Exact literal path match (
- Access Logging Lifecycle: A request logs once, when the request handler returns (its
finallyblock). For a buffered page or asset that is when the body has been handed to the transport withres.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;/_healthprobes and SSE pings are excluded from access logs. A buffered page-script failure logs the error response status, such as500, rather than the page's initially selected200. Errors after headers are committed retain the committed status; client disconnects use499.
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:
| Header | Value |
|---|---|
x-content-type-options | nosniff |
x-frame-options | SAMEORIGIN |
referrer-policy | strict-origin-when-cross-origin |
cross-origin-opener-policy | same-origin-allow-popups |
cross-origin-resource-policy | cross-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):
- Server health state changes to
draining, causing/_healthreadiness checks to immediately return503 Service Unavailablewhile/_health/livecontinues returning200 OK. - Idle keepalive connections are closed with
closeIdleConnections(), and HTTP/2 sessions receivesession.close()(GOAWAY) to prevent new streams without resetting active work. - 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. - Registered shutdown handlers (watchers, SSE manager, rate limiter) and the exit stoppers (page worker pools,
pipeline.execchild 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 externalworker.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 getSIGTERM, thenSIGKILLafter 200 ms; a started process that outlived its script still gets it, and the stop waits for that deadline. Before anySIGKILL, the descendants listed atSIGTERMare matched by start time in a freshpssnapshot, so a reused process ID is not signaled. Wherepshas no start time column (the BusyBoxps -o pid,ppidfallback), 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. Eachpssnapshot blocks the main thread (tens of milliseconds on a busy machine), so a stop shares them: one beforeSIGTERMfor every child's tree and the pending leftovers, and one at the deadline for every child's re-check. On Windowstaskkill /pid <pid> /T /Fends the tree instead, run only while the exec child has not exited (after that its pid may name another tree); ataskkillthat cannot run is a single warning, and the child itself is still stopped. A timed-out script's leftovers waiting for theirSIGKILLget 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. - 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()returnsfalse, reading from the stream pauses until the underlying transport emitsdrain. Socket backpressure governs chunk consumption rather than buffering unbounded chunks in memory. - Request-lifetime abort ownership: Each dispatch creates a request-lifetime
AbortControllerconnected 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'ssignaloption 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.apiTimeoutstarts 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 webRequestbody is in use and the transport stream has not ended), the answer is408 Request TimeoutwithConnection: closeand a log line that names the slow upload; otherwise it is504 Gateway Timeoutfor 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 treatsmod.ts?variant=aandmod.ts?variant=bas 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 packageimportsspecifier (#lib/format.ts) from adata: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 beforeapi-route-changedis emitted. It is installed in dev whether or notdirectory.apiexists 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.mtsfiles under every component root (COMPONENT_FILE_REincomponents.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.importRootto the registry, except files underdirectory.pagesor a component root (their own watchers own them) and stylesheets inlined throughassets.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.cjsfiles underdirectory.pagesas script sources (excluding.d.tsdeclarations and.test.*/.spec.*files;isPageScriptSourceinwatch.ts). One that is not a published static asset advances its runtime identity and recompiles the pages that read it. A.jsfile there is a static asset: it is copied to the output, not invalidated. The pages compile watcher observes only.htmlfiles. - 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.
startDevServercallsinstallModuleGraphHookbefore the port is bound. It registers a synchronous, in-threadnode:moduleregisterHooks({ resolve })hook exactly once per process; a second call returns the existing handle. The function is a no-op outside development:bascik --serverandbascik --buildnever install it, andserver-prod.tsdoes not reference the module.?bascik-gentherefore never appears in production URLs or logs. - Edges. On every resolution the hook calls the default resolver first, then records
child -> Set<parent>fromcontext.parentURLfor resolvedfile: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.tsfrom 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 (orscripts.importRoot, when it lies outside the project) and not under anynode_modulessegment 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
serverandstreamjobs 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 retaindata: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'simport.meta.urlcarries the?bascik-gen=Nmarker in development, so code that derives paths from it should strip the query (or useimport.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
AbortSignaland 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.
createAbortGateinrequest-execution.tsdecides 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,executeApiRouteand 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.