Internals
Architecture
Bascik is a Node.js CLI tool written in TypeScript. Its source lives entirely in pkg/src/. This page maps every module to its responsibility and explains how they fit together.
Entry Point
pkg/src/index.ts is the CLI entry point, reached through the bascik bin (bin/bascik.js) declared in package.json. It resolves arguments using cli.ts and dispatches action requests accordingly (simplified):
// index.ts (entry point, simplified)
const decision = resolveCliAction(args);
switch (decision.action) {
case "help":
case "version":
case "error":
// print usage, the version, or the flag error
break;
case "init": {
const { initProject } = await import("./lib/init.ts");
await initProject({
yes: decision.flags.yes,
skills: !decision.flags.noSkills,
});
break;
}
case "add": {
const { addComponents } = await import("./lib/add.ts");
await addComponents(decision.flags.addTargets ?? [], {
/* force, yes, dryRun */
});
break;
}
case "check": {
const { checkProject } = await import("./lib/check.ts");
const findings = await checkProject({ fix: decision.flags.fix });
// exit 1 on errors (or on warnings too with --strict)
break;
}
case "server": {
const { startProdServer } = await import("./lib/server-prod.ts");
await startProdServer();
break;
}
default: {
// "dev" and "build"
const { runTranspile } = await import("./transpile.ts");
await runTranspile({ exitOnError: true });
}
} transpile.ts handles the normal dev and build flow. In build mode it awaits watchFiles() and exits. In dev mode it calls startDevServer() from server-dev.ts, which binds server.ts concurrently with watchFiles(), so the server is already bound to its port by the time transpilation finishes (requests arriving before page transpilation completes receive an in-memory boot page). server-dev.ts and server-prod.ts are deliberately thin: both boot the same shared server.ts, and each holds only what is specific to its mode, so a page that works locally behaves the same when deployed. startServer() orchestrates loading either http.ts or http2.ts based on BascikConfig.http.tls.enabled and returns the origin URL. The server URL is printed after the initial compilation and post phase. Parallel exec tasks do not block dev startup and may log their outcomes later; their completion never triggers compilation.
The dynamic import() calls are intentional: they avoid loading modules when not needed (init and --check exit before reaching transpile.ts; --build never starts the server).
Library Modules
All logic lives in pkg/src/lib/. Each file has a single, well-defined responsibility:
| Module | Responsibility |
|---|---|
boot-page.ts | In-memory dev-server boot page shown during initial transpile. Connects to live reload and refreshes as soon as its own page is in memory, or when the build finishes if it never compiles. |
build-scripts.ts | Executes <script data-bascik-build> blocks as Node.js ESM modules at transpile time, rewrites relative and import-root (@/) ESM imports to absolute file URLs, runs each uncached script in its own fresh child process bounded by a memory-aware semaphore (children share Node's on-disk compile cache unless the user sets or disables NODE_COMPILE_CACHE), caches results on disk, cleans child-process stack traces, and appends sourceURL comments for debugging. The output cache key folds in resolved package identity (see package-identity.ts) so package upgrades and linked-package edits invalidate cached output. |
package-identity.ts | Resolves the external (bare, scoped, subpath) packages a build script imports using Node ESM semantics via a resolver stub in node_modules/.cache/bascik/ (the directory the child executes from), realpaths each resolved entry, and hashes its content and manifest. The package graph is walked deterministically with a bounded depth and a visited-set cycle guard; Node builtins contribute only their runtime name. This is distinct from local file dependency tracking and ensures cache keys reflect package changes. |
check.ts | Static analysis for bascik --check. Scans all pages and components for unresolved custom tags (errors) and unused component files (warnings). Exits with code 1 when errors are found so it can gate CI pipelines. |
cli.ts | Command-line argument parser for the bascik binary, resolving CLI flags into actions that index.ts can execute. |
components.ts | Discovers and loads components, owns discovery caches, and re-exports markup helpers. Discovery scans each configured root in deterministic order, follows symlinks, and rejects duplicate tags. Markup handling lives in component-attribute-scan.ts, component-tags.ts, component-masking.ts, component-props.ts, component-slots.ts, and component-inheritance.ts. |
component-roots.ts | Single definition of "which configured components directory owns this file." findComponentRoot does a boundary-aware longest-prefix match; every consumer (companion-script layout detection, display paths, --check, the exec source-directory warning) calls it instead of testing a single directory string. |
component-expansion-core.ts | The component expansion loop (props, slots, attribute inheritance, nested components, per-instance scoping), host-neutral so the build and the request-time renderer run the same code. component-expansion.ts is the build's thin wrapper over it. |
component-manifest.ts | Builds .bascik/dist/components.json when a site has server or stream scripts: each component as a scoping template with its CSS and CSP hashes, its staged component_script_* jobs, or the reason it cannot be rendered at request time. Re-exports component-manifest-file.ts. |
component-manifest-file.ts | The manifest as a file: its types and schema, and how components.json is written, read and validated. The prod server, bascik --check and the request-time renderer import it instead of the builder, so serving a release never loads the build pipeline. |
ordered-output.ts | Runs tasks a few at a time and prints their console output and stdout/stderr writes in task order, as a one-at-a-time loop would; the component manifest builds its components through it. |
config.ts | Loads and merges bascik.config.ts, default config, and build overrides into a single frozen BascikConfig object consumed everywhere else. |
css-minifier.ts | Built-in CSS minifier that collapses whitespace, strips comments, and compresses component <style> blocks and global .css files. |
defineConfig.ts | Provides the defineConfig helper function to offer autocomplete and type safety when writing bascik.config.ts. |
environment.ts | Loads .env files (default ./.env plus repeatable --env-file) without clobbering real environment variables, and resolves the site URL from --site-url, BASCIK_SITE_URL, or .env. |
events.ts | A simple Node.js EventEmitter shared between the watch system, processing pipeline, and HTTP servers to signal live-reload and build events. |
exec.ts | Runs commands from the exec configuration list: pre and post entries sequentially in array order, parallel entries concurrently, on build or during file-watching changes. |
file-system.ts | File-system helpers: recursive directory listing, path resolution between source and dist, copying static assets. |
html-minifier.ts | Built-in HTML minifier that strips HTML comments and collapses unnecessary whitespace between tags in production builds. |
html-scanner.ts | One linear scan that follows the WHATWG HTML tokenizer to find the comments and <script> elements a browser actually parses, including in malformed markup. The minifier and the production live-reload strip remove or move only what it reports, so they never make text or dead markup run, never stop a script from running, and never join the text around a removal into new markup. |
http.ts | Plaintext HTTP/1.1 server (node:http) used by default in development and cleartext environments. |
http2.ts | TLS-enabled HTTP/2 server (node:http2) used when http.tls.enabled: true is configured. |
init.ts | Bootstraps a new Bascik project via bascik init. Creates src/pages/index.html and src/components/, ensures .gitignore includes dist/ and node_modules/.cache/bascik/, patches package.json with "type": "module" (when absent), an @bascik/bascik dependency, and dev/build scripts, and writes or merges .vscode/settings.json ("html.validate.scripts": false) and .vscode/extensions.json (recommends the Bascik extension) through vscode-settings.ts, keeping what those files already hold. Third-party linter configs are left untouched. |
vscode-settings.ts | Writes or merges .vscode/settings.json and .vscode/extensions.json for bascik init. Keeps comments, other entries, and a value the user already set, and leaves a file that is not valid JSON alone. An identical copy at create/src/vscode-settings.ts serves npm create bascik@rc; a unit test fails when they differ. |
javascript.ts | The scoping transforms: prefixElementAttribute (rewrites HTML attributes, JS DOM selectors, and CSS) and namespaceScriptTags (wraps scripts in IIFEs with sourceURL annotations and line positioning). |
js-minifier.ts | Lightweight, built-in JavaScript minifier that strips comments and collapses safe whitespace without breaking statement boundaries (ASI). |
typescript.ts | Browser TypeScript boundary. Strips erasable types from referenced .ts/.mts companions (called from getComponentScripts) and from inline type="text/typescript" blocks (called from listComponents for components and transpilePage for pages), always before scoping and minify.js. Diagnoses TypeScript syntax in unmarked <script> blocks without rewriting them. Uses Node's stripTypeScriptTypes in strip-only mode. |
live-reload.ts | Injected client-side script that establishes an EventSource connection to the dev server to reload pages when they are updated. |
manifest.ts | Collects written file metadata (forward-slash path, SHA-256 hash, byte size) as writes occur and writes .bascik/dist/manifest.json when generate.manifest is enabled. Populated only on the main thread (see Artifact Accounting Ownership); workers never record. |
mem.ts | In-memory page store. Stores brotli-compressed page buffers keyed by HTTP path, and maintains a reverse index mapping each component name to the set of pages that use it. |
mime.ts | A static MIME type map used by the shared server core and by build-time precompression. |
names.ts | Generates unique instance IDs (deriveInstanceId) and hashes long scoped names to short alphanumeric strings (minifyAttributeName via SHA-256 with Base62 encoding) when identifier minification is enabled. |
page-worker.ts | Worker thread entry point. Receives a page job, calls transpilePage() with disk publication deferred (workers never write or record manifest/CSP), encodes the rendered HTML to UTF-8 bytes, and posts the result back with that ArrayBuffer in the postMessage transfer list so the page crosses the thread boundary with no structured clone copy. |
paths.ts | Converts file-system paths to HTTP paths (stripping the src/pages prefix, removing .html extensions). |
pki.ts | Generates a self-signed TLS certificate (bascik-cert.pem / bascik-privkey.pem) via OpenSSL or PowerShell on Windows. |
processing.ts | Schedules page jobs, dispatches workers, and owns generations and publication queues. Re-exports the existing compiler entry points. page-transpile.ts compiles pages and accounts for written artifacts; component-expansion.ts expands instances; page-minification.ts adapts minifiers; page-diagnostics.ts owns source locations and errors. Shared data contracts remain in types.ts; module-specific types stay with their implementation. |
rate-limit.ts | In-memory sliding-window rate limiter for server request protection. |
module-graph.ts | Development-only module dependency graph and node:module resolve hook. Records importer edges for project files, appends ?bascik-gen=N to invalidated modules, and advances generations transitively so editing a helper reloads every entry that imports it. Never installed by the production server or the build. |
script-registry.ts | Manages compiled script registries and handler maps for request-time execution. |
server-dev.ts | Dev server additions (bascik). Installs the module graph resolve hook, binds the shared server.ts immediately, starts dev exec scripts alongside it, and flips the boot flag (boot-done) once the initial transpile lands. |
server-prod.ts | Production server additions (bascik --server). Pre-loads pre-rendered dist/ HTML and the server-script sidecar into mem.ts, then boots the shared server.ts. |
server-scripts.ts | Loads and executes <script data-bascik-server> and <script data-bascik-stream> blocks at request time, remapping stack traces to the authored file and line before emitting markup into the page stream. |
server-sidecar.ts | Production sidecar manager for server script registry serialization and startup loading. |
request-render.ts | Host-neutral request-time renderer (wrapRunner): expands component tags in server and stream script output from the manifest, runs nested component scripts within budgets, and emits each component's CSS once per response. |
request-render-node.ts | The Node host for request-render.ts: name and id hashing, the component script jobs built once per manifest, and the development log. |
server.ts | Shared server core used by both dev and production. Dispatches to http.ts or http2.ts based on BascikConfig.http.tls.enabled, runs the request handler, and manages server instances. |
scoping-template.ts | Runs the attribute and script scoping pipeline per component instance (runScopingPipeline) and reuses a verified result for repeated identical inputs by renaming instance-dependent names (scopeComponentInstance). Output is byte-identical to scoping every instance in full. |
scoping-instantiate.ts | Host-neutral template instantiation: replays a recorded ScopingTemplate's name hashes for a new instance id, so request-time output matches the build's scoping byte for byte. |
shielding.ts | Central string shielding utility protecting raw-text elements, comments, and preserved blocks from scoping regex transforms. |
sitemap.ts | Generates dist/sitemap.xml and dist/robots.txt at the end of a build when generate.sitemap / generate.robots are enabled (both default to true). Fails the build when enabled but no site URL is available. |
sse.ts | Server-Sent Events (SSE) connection manager powering live reload and dev server notifications. |
stack-trace.ts | Cleans and remaps stack traces from temporary script files back to original source template files and line offsets. |
stored-page.ts | StoredPage, the shape of a page in the mem.ts store: its content, Brotli and Gzip buffers, component usage, server script plan, and component manifest. |
styles.ts | All CSS transformations: element selector conversion, class prefixing, @keyframes / @layer / container scoping, custom property prefixing, CSS deduplication. |
types.ts | Central TypeScript type definitions: BascikComponent, ComponentList, TranspileResult, TranspilePageResult, BascikConfigOptions. @bascik/bascik/config re-exports every type here, so nothing in it may use a Node-only type such as Buffer (public-types.integration.test.ts typechecks the published declarations without @types/node). |
userConfig.ts | Dynamically imports the user's bascik.config from process.cwd(), exporting config and buildConfig. |
watch.ts | Sets up chokidar watchers (loaded lazily through dev-watcher.ts) for pages, components, and static assets. Triggers full or selective re-transpilation on file events; bursts of full rebuilds share one trailing run through coalesced-run.ts. |
worker-pool.ts | Generic worker thread pool. Spawns workers once, configures each with a settings message, dispatches tasks via a queue, and reuses workers across calls to avoid per-task spawn overhead. grow and shrink resize it, so page compilation can start it early and fit it to the job count later. |
Dependency Graph
The data flow at a high level:
index.ts
├── (init only)
│ └── init.ts
├── (--check only)
│ └── check.ts ← components.ts, file-system.ts
├── (--server / prod server)
│ └── server-prod.ts → server.ts
└── transpile.ts
├── (dev only) server-dev.ts → server.ts
├── config.ts ← userConfig.ts ← bascik.config.ts / bascik.config.js
├── watch.ts
│ └── processing.ts
│ ├── components.ts ← file-system.ts
│ │ └── typescript.ts
│ ├── page-transpile.ts
│ │ ├── component-expansion.ts
│ │ │ ├── component-expansion-core.ts → component-* markup helpers
│ │ │ └── scoping-template.ts → javascript.ts, styles.ts, names.ts
│ │ ├── page-minification.ts → css-minifier.ts, js-minifier.ts
│ │ ├── html-minifier.ts → html-scanner.ts
│ │ └── build-scripts.ts, styles.ts, typescript.ts
│ ├── page-diagnostics.ts
│ ├── component-manifest.ts (server or stream scripts only)
│ ├── worker-pool.ts → page-worker.ts
│ │ └── (transpilePage - no side effects)
│ ├── mem.ts ← paths.ts
│ └── events.ts
└── (dev server, concurrent with watch.ts)
└── server.ts ← server-scripts.ts, mem.ts, events.ts, paths.ts, mime.ts
├── (if http.tls.enabled: true)
│ └── http2.ts → pki.ts
└── (default HTTP/1.1)
└── http.ts Key Design Decisions
Frozen config singleton
BascikConfig is initialized once at startup via config.ts and frozen with Object.freeze(). Every module imports this singleton directly. There is no dependency injection, configuration is a module-level constant that is immutable at runtime.
Zero production dependencies
Bascik has zero mandatory production dependencies. Everything at production runtime (bascik --server) and static build time (bascik --build) uses Node.js built-ins (node:fs, node:http2, node:crypto, node:zlib). chokidar is an optional dependency loaded lazily and used solely by the local development server for file watching, and parse5, the other optional dependency, is loaded only by bascik --check to validate the compiled dist/ HTML. This keeps the production install footprint minimal, enables zero-dependency container deployments (via --omit=optional), and eliminates version-conflict surface area for users.
Avoiding ASTs and browser emulation
Bascik does not use abstract syntax trees (ASTs), DOM parsers (e.g. htmlparser2, parse5), or heavy browser emulation environments (such as JSDOM or Puppeteer) during transpilation. Early prototypes explored these paths but quickly hit significant barriers: JSDOM and browser-level emulation added enormous CPU and memory overhead, while full AST construction introduced complex tree-traversal bottlenecks that severely limited performance.
Instead, Bascik uses high-performance, raw-string manipulation powered by targeted regular expressions combined with temporary content masking (e.g. shielding <script>, <style>, and <textarea> tags). Fast .includes() string-existence guards skip regex execution when target markers are absent, and HTML tag boundary scans use O(1) backwards offset lookups (lastIndexOf) rather than full-string slicing to eliminate O(N^2) heap allocations. This regex-first strategy keeps compile times down to single-digit milliseconds per page, ensures zero external package bloat, and aligns with the project philosophy of being lightweight and fast. The Scoping Compatibility page documents the known limitations and edge cases of this regex approach.
Native TypeScript and ESM Compilation
Bascik embraces Node.js's native TypeScript support (Node 24.21 or newer). At runtime, Node natively strips types to execute .ts files (such as bascik.config.ts, user scripts, and tests) without a separate transpilation step.
For package distribution, the source code in pkg/src/ is compiled by tsc using tsconfig.build.json to emit optimized, pure ESM into pkg/dist/. This pre-compilation ensures that consumers can run the CLI with minimal startup overhead, while local tests and TypeScript features are supported via tsconfig.json and Vitest.
Compiled directive scripts (build, routes, server and stream scripts) are also parsed as ES modules at build time, so an early error such as a redeclared let fails the build, not the first request (checkDirectiveModuleSyntax in typescript.ts). Each thread that builds pages parses them in one shared checker, a worker thread of its own that runs vm.SourceTextModule and never links or runs the module, so a build of valid scripts starts no process; only a module that does not parse is checked again with node --check for the located message. When the checker cannot start, crashes or does not answer within 30 seconds, the thread prints one warning and checks each script with a node --check process until the next build, which starts a fresh checker. A check that does not finish within 30 seconds is skipped with a warning that names the file. The checker's stdout and stderr are piped and never read (reading them would hold the process open while it is idle), so it drops its own writes instead of buffering them.
CPU-aware worker pool (opt-in)
When pipeline.workers: true is set in bascik.config.ts (default false), processAllPages() transpiles pages on worker threads via worker-pool.ts instead of sequentially on the main thread. The pool starts as soon as the page list is known, before the component list has loaded and before dynamic routes are expanded, so each worker loads the transpiler's modules while the main thread does that work. It starts with one worker per page, capped by os.cpus().length and the build-script child budget (derived from available memory, which script-runner.ts reads from vm_stat on macOS, where os.freemem() and older process.availableMemory() under-report it), so a small site never loads the transpiler in a worker it will not use. Once expansion has produced the job list the pool is fitted to the job count with the same caps: it grows when a dynamic route expanded to more jobs than there are pages, and is trimmed when a routes script found no routes. A trimmed worker, like every worker when the pool is torn down, is stopped only once it has loaded, and by asking it to end itself (a stop message after which it closes its port and lets its thread exit) rather than by worker.terminate(), which is kept as the fallback for a worker that has not exited after 3 s. Each worker receives the componentList and globalStylesHtml once, in a settings message sent before its first page, then is reused for every page assigned to it. The main thread dispatches page jobs through the pool's task queue and collects results to apply side effects (memory storage, event emission) after all workers complete. Each result's HTML arrives as a transferred Uint8Array rather than a cloned string; the main thread wraps it in a Buffer view and forwards those bytes directly to the in-memory page store and the disk writer without decoding.
Spinning up the pool has a fixed cost, each worker loads the transpiler's module graph independently before it can process its first page. This pays for itself on larger sites with CPU-heavy per-page work, but for small sites (or sites whose slow parts are I/O-bound, like <script data-bascik-build> blocks) sequential transpilation on the main thread is often faster overall. See the pipeline.workers config option.
Memory-first dev serving
In dev mode, pageProcessing() writes the transpiled HTML to the in-memory store and emits the "transpiled" event before writing anything to disk. The server can serve the updated page immediately. The dist/ write is then queued in the background, one write at a time per page, and skipped when a newer edit to that page has superseded it, so serving never waits for file I/O. In --build, each write is awaited.
Canonical page paths
paths.ts is the single source of truth for converting page filenames into canonical URL paths. The in-memory server key, BASCIK_PAGE_PATH build-script value, and sitemap generator all use the same conversion. Directory indexes use a trailing slash, so src/pages/blog/index.html becomes /blog/. Sitemap serialization percent-encodes each segment after canonicalization, while the server decodes incoming paths before lookup.
Process-isolated build scripts vs in-process server scripts
Bascik separates the execution model of build-time scripts from request-time server scripts:
- Build scripts (
<script data-bascik-build>): Executed in complete isolation. Instead of usingevalor Node'svmmodule, which can leak state or restrict standard Node.js APIs, Bascik rewrites relative./and../ESM imports against the source page or component directory and@/imports against the configured import root (scripts.importRoot), writes build script content to a temporary.mjsfile on disk, and runs each uncached script as its OWN standalone Node.js subprocess, bounded by a memory-aware semaphore. There is no shared-process batch: every script gets a fresh ESM module registry, its own stdout/stderr streams, and its own environment, so execution isolation is independent of how many sibling scripts were cache misses. This process-level isolation ensures that user build scripts cannot pollute the memory of the compiler or server, natively supports ES modules, top-levelawait, dynamic imports, and invokes the callable default export to transport returned HTML cleanly through a separate result channel before removing temporary files. - Server scripts (
<script data-bascik-server>): Executed in-process at request time as Node.js ESM modules viaScriptRegistry. Duringbascik --build, the script source is extracted into the sidecar (.bascik/dist/server-scripts.json) and replaced in the HTML with an inert placeholder tag (<script type="text/bascik-server" data-bascik-server-id="...">). Whenbascik --serverboots, it loads the sidecar into memory once. Incoming requests execute the registered module in-process with a WHATWGRequestand{ remoteIp }context and inject the returned markup into the document flow without subprocess spawn overhead. When the returned markup contains component tags,request-render.tsrenders them from the component manifest (.bascik/dist/components.json), written by the same build; a script inside such a component is staged in the sidecar like a page script and runs in-process too. See Request-time component rendering.
To maintain accurate debugging diagnostics across execution boundaries, Bascik appends //# sourceURL directives and cleans stack traces (stack-trace.ts), mapping temporary paths and line offsets back to the original source HTML file.
Incremental rebuilds via reverse component index
To keep the development server instantaneous, Bascik avoids full site rebuilds on change. The in-memory store (mem.ts) maintains a reverse dependency index mapping each custom component tag to the exact list of pages that consume it. When a component file is modified, the file-system watcher resolves the component's name, checks the reverse index, and schedules only the affected pages for re-transpilation. A stylesheet that component CSS @imports is mapped to its importing components at listing time (componentsImportingStylesheet), so editing it rebuilds those components' pages too. Unaffected pages remain cached in memory.
Custom lightweight JS minifier
Rather than bundling heavy engines like Terser, esbuild, or SWC as dependencies to minimize scoped scripts and IIFEs, Bascik includes a custom, regex-based segment minifier (js-minifier.ts). It strips comments, collapses redundant whitespace, removes safe operator padding, and respects statement boundaries (ASI) without mutating literal strings or regex patterns. This approach keeps dependencies at zero and avoids any performance impact on transpilation.
Zero-dependency live reload via Server-Sent Events (SSE)
Instead of pulling in a WebSocket dependency like ws or injecting a heavy client library, Bascik implements live reloading via native Server-Sent Events (SSE). The dev server uses a lightweight request handler built on Node's standard http and http2 modules, while pages are injected with a tiny, twenty-line client script that listens via EventSource. This ensures that live reloading is reliable and completely dependency-free, and adds zero footprint to development builds.
Brotli-compressed in-memory cache
To achieve fast response times in both dev and production modes, Bascik keeps transpiled pages in an in-memory store (mem.ts). Storing large raw HTML strings in V8 memory can lead to high memory consumption and GC pressure on large sites. To prevent this, Bascik compresses all cached pages using Node's native node:zlib Brotli implementation. This reduces the dev server's RAM footprint by up to 90% while allowing production environments to stream pre-compressed buffers directly to modern browsers.
CLI startup and package manager overhead
Bascik's Node.js CLI runtime (pkg/dist/index.js) boots and starts transpilation in under 50ms. When invoking commands through monorepo package managers (such as yarn <workspace> <script>), the package manager itself may spend 1.0 to 1.5 seconds loading plugins, parsing configuration files, and resolving workspace dependency graphs before it spawns the Node.js process. The framework's logged completion metrics (e.g. ✓ All tasks completed in Xms and ✓ Build complete in Xms) measure total task execution within the running Node process. For instant CLI execution with sub-50ms startup latency, invoke the compiled binary directly via node (e.g. node ./node_modules/.bin/bascik) or use a lightweight script runner.