This file captures architectural knowledge, non-obvious design decisions, known limitations, and development patterns for the expediate package. It is intended for future conversations — read this before touching any source file.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
Don't assume. Don't hide confusion. Surface tradeoffs.
Before implementing:
State your assumptions explicitly. If uncertain, ask. If multiple interpretations exist, present them - don't pick silently. If a simpler approach exists, say so. Push back when warranted. If something is unclear, stop. Name what's confusing. Ask.
Minimum code that solves the problem. Nothing speculative.
No features beyond what was asked. No abstractions for single-use code. No "flexibility" or "configurability" that wasn't requested. No error handling for impossible scenarios. If you write 200 lines and it could be 50, rewrite it. Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
Touch only what you must. Clean up only your own mess.
When editing existing code:
Don't "improve" adjacent code, comments, or formatting. Don't refactor things that aren't broken. Match existing style, even if you'd do it differently. If you notice unrelated dead code, mention it - don't delete it. When your changes create orphans:
Remove imports/variables/functions that YOUR changes made unused. Don't remove pre-existing dead code unless asked. The test: Every changed line should trace directly to the user's request.
Define success criteria. Loop until verified.
Transform tasks into verifiable goals:
"Add validation" → "Write tests for invalid inputs, then make them pass" "Fix the bug" → "Write a test that reproduces it, then make it pass" "Refactor X" → "Ensure tests pass before and after" For multi-step tasks, state a brief plan:
[Step] → verify: [check]
[Step] → verify: [check]
[Step] → verify: [check]
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
Expediate is a zero-dependency TypeScript HTTP server framework with an
Express-compatible API surface. It wraps Node.js built-in http/https modules
only. There are no runtime npm dependencies whatsoever.
- Package type: ESM-first (
"type": "module") with a CJS compatibility shim - Version: 1.0.5
- Build:
npm run build:esm(tsc) +npm run build:cjs(node scripts/build-cjs.cjs, CJS bundle via esbuild) → outputs to./dist/.npm run buildruns both. - Test runner:
node --import tsx --test 'tests/*.test.ts' - TypeScript version: 5.9.3 (strict mode, isolatedModules, esModuleInterop)
- tsconfig target: ESNext, module NodeNext
- Outputs:
- ESM:
.js,.d.ts,.d.ts.map,.js.mapfiles indist/(tree-shakeable, one file per module) - CJS: single bundled
dist/cjs/index.js+dist/cjs/package.json({"type":"commonjs"})
- ESM:
Source files live in src/. Only src/**/* is compiled (tsconfig.json).
Test files in tests/ use tsx at runtime and are never compiled.
| Source file | Exports (from src/index.ts) |
|---|---|
router.ts |
createRouter, types: Router, RouterOptions, RouterRequest, RouterResponse, Middleware, MiddlewareArg, NextFunction, ErrorHandler, ErrorMiddleware, Layer, RouteInfo, RouteBuilder, CookieOptions, TlsOptions, StringMap |
static.ts |
serveStatic, serveFile, sendFile, mime, types: StaticOptions, Mime |
misc.ts |
json, formData, formEncoded, raw, text, parseBody, streamFormData, logger, cors, parseMultipartBody, types: BodyOptions, BodyTypeMatcher, VerifyFn, LoggerOptions, FormPart, FormPartStream, CorsOptions |
middleware.ts |
compress, requestId, rateLimit, cacheControl, csrf, securityHeaders, conditionalGet, types: CompressOptions, RequestIdOptions, RateLimitOptions, CacheControlOptions, CsrfOptions, SecurityHeadersOptions |
jwt-auth.ts |
createJwtPlugin, createMapTokenStore, types: JwtPlugin, JwtConfig, TokenStore, RefreshTokenRecord |
git.ts |
gitHandler, gitCreate, types: GitHandlerOptions |
apis.ts |
apiBuilder, defineController, types: ApiError, ApiContext, ServiceMethod, ServiceInstance, ServiceMethods, RouteMap, ServiceDefinition, ControllerDefinition, Guard, AuthBinding, ApiBuilderOptions, ApiRouter, ApiRouterExtensions |
openapi.ts |
describe, openApiSpec, serializeSpec, DESCRIBE_META, types: JsonSchema, ParameterObject, RequestBodyObject, ResponseObject, OperationMeta, OpenApiServiceMeta, RouteOpenApi, ControllerOpenApi, ServiceOpenApi, OpenApiSource, SpecOptions, SpecFormat, OpenApiDocument |
Note: extractCharset and readReqBody are exported from misc.ts directly but not
re-exported through index.ts — they are implementation details also used by router.ts.
Similarly, collectRoutes and validateSchema are exported from apis.ts (consumed by
openapi.ts and the test suite respectively) but are not part of the public package API.
Three strategies, chosen automatically by compilePattern():
-
Glob string — contains
*or?(unescaped) →compileGlob()?→[^/](one non-slash char)*→[^/]*(any non-slash chars, zero or more)**→.*(any chars including slashes — cross-segment)- Result is anchored at
^, no$(prefix match)
-
Plain string — no wildcards →
compilePlainPath()- Segments starting with
:become named capture groups(?<name>[^/]+) - Inline regex constraints —
:name(pattern)replaces the default[^/]+body with the given pattern::id(\d+)→(?<id>\d+). A literal suffix after the closing)is regex-escaped and appended (e.g.:id(\d+)px→(?<id>\d+)px). Named capture groups inside constraints are rejected at registration time (they conflict with the outer wrapper). - Literal segments are regex-escaped
- Pattern ends with
(?=/|$)to avoid partial segment matches (so/usersdoes NOT match/users-admin) - Anchored at
^ isGlobPattern()walks character-by-character and explicitly skips over:name(constraint)balanced-paren blocks so regex metacharacters (?,*) inside constraints are never mistaken for glob wildcards
- Segments starting with
-
RegExp — used as-is; named capture groups become
req.params
This is the most important design detail in the whole codebase:
-
use(path, mw)→stripPath: true— the matched prefix is removed fromreq.pathbefore the middleware runs. Nested routers only see the remaining suffix. Path is restored after the sub-router callsdone(). -
all/get/post/put/delete/patch(path, mw)→stripPath: false—req.pathis left intact so multiple chained middlewares for the same path each see the full unmodified path.
The path restoration logic is in the listener function of createRouter():
const pathBefore = req.path;
if (layer.stripPath) {
return layer.middleware(req, res, () => {
req.path = pathBefore; // restore for the next sibling
next();
});
}Without this restoration, sibling layers registered after a use() sub-router
would see a truncated path and fail their own pattern matches.
Called at the start of every listener invocation. Idempotent — checks
rReq.queries and exits early if already set (important for nested routers
sharing the same request object).
Fields added to req:
originalUrl— the raw URL string, never modifiedpath— pathname portion of the URL; modified byuse()layersparams— merged map (URL query params first, then named route params on match)queries— structured object{ url?: StringMap, route?: StringMap }cookies— parsed fromCookieheader (signed cookies verified with HMAC-SHA256 whensecretis set)ip— remote client IP; respectsX-Forwarded-ForwhentrustProxy: truejson(opts)— Promise-based JSON body reader (usesreadReqBodyfrom misc.ts)text(opts)— Promise-based plain-text body readerformData(opts)— Promise-based multipart/form-data body readerheader(name)— reads a request header (case-insensitive;referer/referrertreated as equivalent)
Fields added to res:
send(data?)— writes data and callsres.end()json(data)— serialises to JSON and endsstatus(code, headers?)— sets status code and optional headers, returnsthisfor chainingredirect(url)— 302 withlocationheadercookie(name, val, opts)— appendsSet-Cookieheader, returnsthisfor chainingdownload(filepath, filename?)— setsContent-Disposition: attachmentthen streams the filetype(mime)— setsContent-Typeheader, returnsthisfor chainingetag(value, strong?)— setsETagheader (W/"value"weak by default,"value"whenstrong=true), returnsthisfor chainingheader(field, value)— sets a response header (chainable wrapper oversetHeader), returnsthisfor chaining
The X-Powered-By: Expediate header is set on every response in updateHttpObjects.
- Cookie reading: each value is first de-quoted (RFC 6265 quoted-string) and percent-decoded (
decodeCookieValue, falls back to the raw string on malformed%-sequences). Thenj:prefixed values are JSON-parsed ands:prefixed values are HMAC-SHA256 verified against the router secret whensecretis configured increateRouter(); cookies that fail verification are silently dropped. - Cookie writing: objects get
j:prefix.opts.signed = truetriggers HMAC-SHA256 signing (requiressecreton the router). The final value (after anyj:/s:wrapping) is percent-encoded withencodeCookieValueso semicolons, commas, spaces, quotes, and backslashes transmit safely; the HMAC is computed over the unencoded value, keeping sign/verify symmetric.
The invoke() helper catches both synchronous throws and async rejections:
const invoke = (mw: Middleware, nextFn: NextFunction): void => {
try {
const ret = mw(req, res, nextFn) as unknown;
if (ret instanceof Promise) ret.catch(invokeErrorHandler);
} catch (e) {
invokeErrorHandler(e);
}
};A caught error (sync throw, async rejection, or next(err)) is routed to invokeErrorHandler, which resolves it through an ordered cascade (see "Error channel" below). The next(err) calling convention skips remaining routes and enters this cascade directly.
invokeErrorHandler(e) runs a per-request runNext(err) cascade:
error()middleware chain — handlers registered viarouter.error(handler)(ErrorMiddleware = (err, req, res, next) => void, err first to distinguish from normal middleware) run in registration order. Each may end the response or callnextto forward:next()re-forwards the same error,next(newErr)replaces it. A throw inside an error handler is caught and forwarded to the next one (runNext(e2)).onError()terminal fallback — if the chain is exhausted without responding, the singleonError(err, req, res)handler runs (if registered). It is the simple, no-nextcatch-all; preserved for backward compatibility.- Bubble to parent — if neither responded and the router was mounted as a sub-router,
done(err)is called.doneis the parent listener'snext, so the error enters the parent's error channel. This is how a root-level handler catches failures from deeply nested routers. - Default 500 — a top-level router (
done === undefined, e.g. viahttp.createServer(router.listener)) with no handler logs and sendsError <method> <url>.
The stripPath continuation in the dispatch loop forwards the error: invoke(layer.middleware, (err?) => { restore req.path/baseUrl; next(err); }). Without this, a bubbled error would lose its payload (and req.path) crossing a use() boundary. The continuation does double duty — no-arg call = sub-router fell through (404 delegate), err call = bubbled error.
Async caveat: only the returned promise is tracked. A middleware that calls next() then throws later from a detached callback (setTimeout, event emitter) is not caught.
listen(port: number, opts?: TlsOptions | (() => void), cb?: () => void):
http.Server | https.Server | http2.Http2SecureServerWhen opts is a function it is treated as the callback. HTTPS is used when opts.key and opts.cert are present. HTTP/2 is used when opts.http2 = true. Returns the underlying server instance — use it for port discovery (server.address()), graceful shutdown, or attaching extra event listeners.
Three public factories, all accepting BodyOptions:
json(opts?)— strictapplication/jsononly →req.bodyis parsed JS value. Also attachesres.json(data)helper (note: router.ts also attachesres.jsoninupdateHttpObjects, so it's idempotent).formData(opts?)—multipart/form-data→req.bodyisFormPart[]. EachFormParthasheaders: Record<string,string>andcontent: Buffer.parseBody(opts?)— auto-detects MIME type; supportsapplication/json,multipart/form-data,text/plain.
interface BodyOptions {
inflate?: boolean; // default: true — accept gzip/deflate
limit?: string|number; // default: '100kb'
reviver?: Reviver|null; // default: null — JSON.parse reviver
strict?: boolean; // default: true — enforced (see below)
}strict restricts JSON parsing to top-level objects/arrays: when true
(the default), a bare top-level primitive (string, number, boolean, or
null) is rejected with 400 Bad Request: JSON body must be an object or array (readBodyAsJson in misc.ts, tagged FIX-10). Covered by the
'json() strict mode (FIX-10)' suite in tests/misc.test.ts.
Parses strings like '100kb', '2mb', '1gb' (case-insensitive). Returns 0
on parse failure, which causes the code to fall back to 102_400 bytes (100 KB).
Only gzip and deflate are supported. Unknown Content-Encoding values
produce 415 when inflate: true.
- Wire delimiter is
\r\n--${boundary}(not just--${boundary}) - Binary-safe: uses
splitBuffer()which operates on rawBuffer - The first part is handled by prepending
\r\nto the body before splitting - Parts with missing
\r\n\r\nseparator are silently skipped
All options are optional. Key fields:
{
track: boolean; // per-request timeout tracking
trackTimeout: number; // ms (default: 30_000)
user: (req) => string; // identity extraction
locale: string; // Intl.DateTimeFormat locale
dateFormat: Intl.DateTimeFormatOptions;
json: boolean; // structured JSON logging (undocumented in README)
logger: (msg: string|object) => void; // custom log fn
}When json: true, the logger calls log({timestamp, status, method, path, ip, user, elapsed, host, length}) instead of a formatted string.
{
origin: string | string[];
allowHeaders: string | string[];
allowMethods: string | string[];
allowCredentials: boolean | undefined;
maxAge: number | undefined;
vary: string | string[] | undefined;
optionsStatus: number; // default: 204
preflight: ((req) => boolean) | undefined;
}When preflight returns false, OPTIONS requests get 403, non-OPTIONS get 400.
CORS headers are only set when req.headers.origin is present (browser-only).
readReqBody(req, opts, mimetype)→Promise<{mimetype, content}|null>— used byreq.json()attached in router.tsextractCharset(contentType)→string— parsescharset=from Content-Type header- Both are exported from
misc.tsbut not fromindex.ts
serveStatic(root, opts?)→ middleware that serves files fromrootdirectoryserveFile(filepath, opts?)→ middleware that serves a single specific filesendFile(filepath, req, res)→ utility that sends a file directly (no middleware wrapping)mime— the MIME type lookup object
fallthrough— whentrue(default), unmatched paths callnext()instead of 404dotfiles—'allow'|'deny'|'hide'(default:'deny')headers— additional response headers merged with defaultscontentType— override the MIME typeindex— index file name (default:'index.html')redirect— redirect directories to trailing slash (default:true)
Default headers include a Content-Security-Policy and X-Content-Type-Options: nosniff.
ETags are weak, format: W/"<size_hex>-<mtime_hex>". The router checks:
If-None-Matchagainst the ETag → 304 Not ModifiedIf-Modified-Sinceagainst the file'smtime→ 304 Not ModifiedCache-Control: no-cacheforces full response even when ETag matches
Only GET and HEAD are served; other methods return 405 Method Not Allowed (with fallthrough to next middleware if enabled).
UP_PATH_REGEXP = /(\/|^)(\.\.?)(\/|$)/ guards against .. sequences. The
test file even tests raw injection (req.path set to /../../../../etc/passwd).
src/mimetypes.json is loaded via require('./mimetypes.json') — CommonJS.
This is not a generated file; it is committed to the repo.
writeIndexOf() generates an Apache-style HTML directory listing with sortable columns. Sorting is controlled by query parameters:
C— column:N(name, default),M(mtime),S(size)O— order:A(ascending, default),D(descending)- Both
;and&separators are accepted (e.g.?C=S;O=Dor?C=S&O=D)
Directories always sort before files regardless of column or direction. Column header links toggle the sort direction when clicked (active column) or default to ascending (inactive column). Invalid C/O values silently fall back to name/ascending.
The plugin is a factory function returning a JwtPlugin object. Everything is
closure-scoped to a single config instance.
const auth = createJwtPlugin({ accessTokenSecret: 'my-secret' });
app.post('/auth/login', json(), auth.login);
app.post('/auth/refresh', json(), auth.refresh);
app.post('/auth/logout', json(), auth.logout);
app.get('/me', auth.authenticate, auth.authorize, handler);
app.delete('/admin', ...auth.requireRole('admin'), handler);
app.put('/data', ...auth.requirePermission('write'), handler);Fully manual — no external library:
- Base64URL encode/decode via
Buffer.from(...).toString('base64')+ char replacements - HMAC (
HS256,HS384,HS512) —crypto.createHmac(), verification withcrypto.timingSafeEqual() - RSA (
RS256,RS384,RS512) —crypto.createSign/createVerify()with PEM keys - ECDSA (
ES256,ES384,ES512) —crypto.createSign/createVerify()with PEM keys; DER-encoded signatures are converted to/from IEEE P1363 format (raw R||S) for standard JWT wire format - For RS*/ES* algorithms,
accessTokenPrivateKeyandaccessTokenPublicKeyare used instead ofaccessTokenSecret
- Access token: standard JWT (header.payload.signature), short-lived (default 15 min)
- Refresh token: also a signed JWT, long-lived (default 7 days), carrying a
jti(crypto.randomUUID()) claim andtype: 'refresh'. TheTokenStorekeys records byjti, not by the token string itself.
Refresh support is entirely opt-in — refreshTokenStore is undefined in
DEFAULT_CONFIG. When no store is configured, auth.login's response omits the
refreshToken field, and POST /auth/refresh responds 501 Not Implemented. Pass
createMapTokenStore() (or a custom TokenStore) to enable it.
refreshTokenSecret / refreshTokenPrivateKey / refreshTokenPublicKey sign the
refresh JWT; for RS*/ES* algorithms they fall back to the access-token PEM keys when
absent. checkIssuer defaults to false. A username extractor function (default:
(user) => user.username) controls how the subject claim is derived from UserRecord.
renewAccessToken() always deletes the old refresh token before issuing a new
pair (rotation). A stolen refresh token can only be used once.
authenticate middleware always clears req.user at the start, preventing
stale data from leaking across requests. It then calls next() silently on
failure — it never rejects the request itself. authorize does the rejection
(401). This split design lets you inspect req.user optionally.
Both return Middleware[] arrays (two elements: [authenticate, checker]) meant
to be spread into route registration:
app.get('/admin', ...auth.requireRole('admin'), handler);requireRole— user must have at least one of the specified rolesrequirePermission— user must have all of the specified permissions
userDatabase (exported) is a Map<string, UserRecord> with three demo users:
alice/password123— roles:['admin', 'editor'], permissions:['read', 'write', 'delete', 'manage_users']bob/secret456— roles:['editor'], permissions:['read', 'write']charlie/pass789— roles:['viewer'], permissions:['read']
Default: SHA-256. Explicitly documented as unsafe for production in the source comments. The intent is to replace with bcrypt/argon2.
signToken and verifyToken are re-exported from jwt-auth.ts for testing and
advanced use:
export { signToken, verifyToken, hashPassword as _hashPassword };apiBuilder(service) returns a pre-configured Router for a "service" — an
object that combines state, helper methods, and HTTP route handlers.
interface ServiceDefinition<TInstance> {
// Instance lifecycle (API-wide, shared by all controllers)
scope?: (req) => string | null; // scoping strategy
data?: (key) => Partial<TInstance>; // factory for initial state
setup?: (this: TInstance) => void | Promise<void>;
methods?: ServiceMethods<TInstance>;
// v2 — composition, guards, auth, validation
controllers?: ControllerDefinition<TInstance>[]; // merged sub-controllers
guards?: Guard[]; // run before every handler
auth?: AuthBinding; // authenticate + check + spec scheme
validate?: boolean | ApiBuilderOptions; // enforce requestBody schemas
schemas?: Record<string, JsonSchema>; // shared validator/spec components
// Root route maps — form an implicit controller with no prefix
GET?: RouteMap<TInstance>;
POST?: RouteMap<TInstance>;
PUT?: RouteMap<TInstance>;
DELETE?: RouteMap<TInstance>;
PATCH?: RouteMap<TInstance>;
}scope field |
Behaviour |
|---|---|
Absent (no scope) |
Singleton — one global instance for all requests |
Returns string |
Keyed — one instance per key, cached in modules |
Returns null |
Ephemeral — fresh instance per request, discarded |
Singleton key in the cache map is 'singleton' (literal string). The instance
lifecycle is API-wide: all controllers share the same instance.
data(key)— creates state object (or{ $key: key }if nodata)- Methods mixed in — each
service.methods[name]is copied as a regular function withapply(instance, args)(NOT arrow functions — this was a past bug) await setup()— the returned Promise IS awaited. Singletons serve 503 Service not ready until it resolves; keyed/ephemeral instances are awaited per request.
interface ApiContext<TUser = unknown, TState = Record<string, unknown>> {
query: { route: Record<string, string>; url: Record<string, string | string[]> };
params: Record<string, string>; // alias of query.route (same object)
path: string;
user?: TUser; // default `unknown` (v2; was `any`)
state: TState; // guard-produced values, starts {}
}- Truthy return value → JSON response, status 200
- Falsy return value (
undefined,null,false,0,'') → 201 No Content - Throw / reject with
ApiError→ HTTP error response:interface ApiError { status?: number; // default: 500 data?: unknown; // JSON body (takes precedence over message) message?: string; // plain text body }
The per-route handler's .catch in buildRoutes runs an optional
service.onError(err, ctx, req) hook before the default sendError
translation:
- returns
undefined→ defaultApiError→ HTTP translation proceeds (log-only). - returns an
ApiError→ that value is sent viasendErrorinstead. - throws →
next(err)is called, escalating to the surrounding app's error channel (router.error()/onError) instead of answering locally. This is whybuildRoutesregisters handlers with the full(req, res, next)signature and threadsnextinto the catch.
ctx is still in scope in the catch, so the hook gets the full ApiContext
(unlike a router-level error() handler, which only sees (err, req, res)).
ControllerDefinition = { prefix?, tags?, guards?, permission?, GET?, POST?, … },
declared via the defineController() identity helper. collectRoutes(service):
- Normalises root route maps into an anonymous controller (
prefix: ''). - Rewrites each path to
joinPath(prefix, path)(duplicate slashes collapsed;'/'+ prefix/p/:proj/wiki→/p/:proj/wiki). - Concatenates everything and sorts by specificity globally.
- Throws at build time on a duplicate
(verb, joined path)pair, naming both declaring controllers (was silent shadowing in v1 — intended break). - Records per-route provenance (
tags, composedguardschain,permission,meta) consumed by both the request pipeline andopenApiSpec().
auth.authenticate (router mw) → auth.check (if route/controller permission)
→ body validation (if validate enabled + meta.requestBody)
→ guards: api → controller → route → handler
- Guards (
(ctx, req) => void | object | Promise<…>): thrownApiErrors become HTTP errors; returned objects shallow-merge intoctx.state. auth.checkdefault: 401 when noctx.user, 403 whenctx.user.permissionsis missing any required entry (mirrorsjwtPlugin.requirePermission). Overridable for resource-scoped models.auth.authenticateis registered viaapi.use('/', …)before the singleton 503 guard.- Validation (
validateSchema): JSON Schema subset —type,required,properties,items,enum,pattern,minLength/maxLength,minimum/maximum,additionalProperties,allOf/anyOf/oneOf,$refresolved againstServiceDefinition.schemas. Failures → 400 with{ message, fieldErrors }; dotted paths, root errors keyed'$'.
All routes (root + controllers) are sorted globally before registration:
score(path) = (segment_count * 100) - (param_count * 10)
Higher score = registered first = wins priority. /items (score 100) is
registered before /items/:id (score 90). This prevents prefix-match collisions.
apiBuilder returns a Router. Mount it with app.use('/prefix', apiBuilder(...)) —
not app.get(). If you mount without use(), the path prefix is NOT
stripped and route patterns must be absolute. (Tests use app.use('/', api)
intentionally to avoid this stripping affecting route resolution in tests.)
Service methods receive body as (req as any).body — a body-parsing middleware
(json() etc.) must run before the route handler for body to be populated.
Routes with a permission (route- or controller-level) get
security: [{ bearerAuth: [] }] plus an x-required-permissions vendor
extension (name overridable via auth.permissionsExtension), and
components.securitySchemes.bearerAuth is emitted once (from auth.scheme,
default HTTP bearer/JWT). openApiSpec() consumes the merged collectRoutes
table, so controllers produce one document; controller tags fill in
operations that declare none. Note the module cycle: apis.ts imports
openApiSpec/DESCRIBE_META from openapi.ts, which imports collectRoutes
back from apis.ts — safe because only hoisted function declarations are used
at call time.
| Method | Path | Service | Purpose |
|---|---|---|---|
| GET | /info/refs |
git-upload-pack |
Capability advertisement |
| GET | /info/refs |
git-receive-pack |
Push capability advertisement |
| POST | /git-upload-pack |
git-upload-pack |
Pack negotiation and transfer |
| POST | /git-receive-pack |
git-receive-pack |
Push pack transfer |
pktLine(str) = padStart(4, '0') hex of (str.byteLength + 4) + str
PKT_FLUSH = '0000'
Flush packet signals end of a PKT-LINE list. The GET /info/refs response
starts with a service banner PKT-LINE then a flush, then the git-upload-pack
output piped directly.
interface GitHandlerOptions {
repository: (req) => string | null | undefined | false; // REQUIRED
gitPath?: string; // directory prefix for git binaries (with trailing /)
strict?: boolean; // --strict / --no-strict (default: false = --no-strict)
timeout?: number | string; // seconds for --timeout=N
}buildArgs() constructs the argument list for git-upload-pack only (not for
git-receive-pack — receive-pack just gets [gitDirectory]).
Runs git init --bare and optionally writes a description file. Used for
programmatic repository creation.
- Spawn errors → 500
- Non-zero exit codes → 500
EPIPEon stdin is silently ignored (client disconnect mid-stream)- gzip-encoded POST bodies are decompressed transparently via
zlib.createGunzip()
Git tests use tests/fixtures/git-repo.git.zip which is extracted to a
temporary directory. The end-to-end git clone test is commented out (requires
git in the test environment).
Seven middleware factories, all exported from src/index.ts. Each uses the standard (req, res, next) signature and imports types from router.ts only (no circular dependencies).
middleware.ts extends the RouterRequest interface with fields set by specific middleware:
declare module './router.js' {
interface RouterRequest {
id?: string; // set by requestId()
csrfToken?: () => string; // set by csrf()
}
}Overrides res.write and res.end to buffer body data. At end() time, if total body ≥ threshold, a compressor stream (Brotli > gzip > deflate) is selected and data is flushed through it. Below threshold, data is written directly. Content-Length is removed; Content-Encoding and Vary: Accept-Encoding are set.
Reads the configured header from req.headers; if allowFromHeader is true and a value is present it is reused, otherwise generator() is called. Sets req.id and echoes the ID in the response header.
Maintains a Map<key, number[]> of request timestamps per client. On each request the list is pruned to the current window, then the count is checked against max. X-RateLimit-* headers are set when headers: true.
Pre-computes the Cache-Control string and Vary value once at factory time. On each request it sets the headers and always calls next().
Double-submit cookie pattern. Reads (or generates) a 256-bit hex token from the cookie, attaches req.csrfToken(), then validates the submitted value for state-mutating requests. The cookie is not HttpOnly so client JavaScript can read it.
Pre-computes all header name/value pairs at factory time. On each request it sets them via a for loop and always calls next().
Overrides res.write (buffer to pending[]) and res.end (check freshness, send 304 or flush buffer). Only GET and HEAD requests participate. Freshness is checked via isFreshResponse() using weak ETag comparison per RFC 7232. A 304 response strips Content-Type, Content-Length, and Content-Encoding, retaining ETag, Cache-Control, Vary, Last-Modified.
res.etag(value, strong?) is implemented in router.ts updateHttpObjects (not in middleware.ts) because it belongs with the other res.* helpers. middleware.ts reads the header that res.etag() sets.
Node.js built-in node:test (no external framework). Run with:
node --import tsx --test 'tests/*.test.ts'Coverage uses Node's native instrumentation (no nyc/c8) via npm run test:coverage
(--experimental-test-coverage --test-coverage-exclude='tests/**'), so the
report covers src/ only.
All test files use a shared pattern:
import { describe, it } from 'node:test';
import assert from 'node:assert';The harness is Node's native test module — no Jest, no Mocha. assert.equal,
assert.deepEqual, assert.ok etc. from node:assert.
Tests spin up real HTTP servers on port 0 (OS assigns a random free port):
const server = http.createServer(router.listener as any);
server.listen(0);
const { port } = server.address() as AddressInfo;
// ... make requests with fetch or http.request ...
server.close();No mocking of HTTP. This catches real behaviour including header casing, chunked encoding, and streaming.
apis.test.ts mounts the API router at '/' with app.use('/', api) — NOT at
a sub-path. This avoids path-stripping interference with the route patterns
defined in the service definition.
Tests for logger() set track: true with a very short trackTimeout to
verify the LOST warning fires. They assert console.log was called with a line
containing 'LOST'.
| Test file | Test suites |
|---|---|
router.test.ts |
compilePlainPath (incl. inline constraints), compileGlob, RegExp, methods, HEAD/OPTIONS/405 handling, chain, sub-router, URL parsing, cookies, response helpers, registerRoute errors, edge cases |
misc.test.ts |
json(), formData(), formEncoded(), parseBody(), streamFormData(), readSize (via limit), logger(), chunked transfer, strict mode |
middleware.test.ts |
compress(), requestId(), rateLimit(), cacheControl(), csrf(), securityHeaders(), conditionalGet() |
apis.test.ts |
singleton/keyed/ephemeral, buildModule, return conventions, error handling, route params, HTTP verbs, multiple routes, async setup, ctx.params alias, controllers (prefix joining, global sort, duplicate throw, shared instance) |
api-guards.test.ts |
guards (ordering, ctx.state merging, ApiError translation, async), auth binding (default check 401/403, controller/route permission, custom check, authenticate auto-registration, createJwtPlugin end-to-end), validateSchema keywords, request validation HTTP pipeline (400 fieldErrors, opt-out) |
static.test.ts |
validation, basic serving, security headers, caching (304/ETag), method filter, dot-files, traversal, directory redirect, contentType, fallthrough, serveFile, sendFile, ETag stability, concurrency, writeIndexOf sorting |
jwt-auth.test.ts |
hashPassword, signToken/verifyToken (HS*, RS*, ES*), login, refresh (rotation), logout, authenticate, authorize, requireRole, requirePermission, custom config, security edge cases |
git.test.ts |
factory validation, pktLine, GET /info/refs, POST /git-upload-pack, repository callback, options (strict/timeout/gitPath), unrecognised routes |
openapi.test.ts |
describe(), openApiSpec(), serializeSpec(), YAML output, path parameters, request/response schemas, merged controller specs, security emission (bearerAuth, x-required-permissions), ServiceDefinition.schemas precedence |
jwt.fuzz.test.ts |
property-based (fast-check): algorithm confusion (alg: none, mismatched alg), signature tampering, wrong-key rejection, expired-token replay, malformed-token robustness (verifyToken never throws) |
multipart.fuzz.test.ts |
property-based: parseMultipartBody terminates within budget on adversarial bytes/boundaries, always returns FormPart[] or throws { status: 400 }, well-formed bodies round-trip correctly |
router-redos.fuzz.test.ts |
property-based: matching any path against plain/param/\d+-constrained/glob routes stays roughly linear (no catastrophic backtracking) |
static-traversal.fuzz.test.ts |
property-based: no crafted path (raw .., %2e%2e, encoded slashes, NUL/control bytes, depth) serves a file above the static root |
The dispatch loop in createRouter()'s listener resolves method semantics
after all layers are exhausted (see the allowedMethods set):
- HEAD is served by the matching GET layer —
matchRouteLayer()treats aHEADrequest as matching aGETroute. The handler runs unchanged and Node suppresses the body for HEAD responses (res._hasBody = false). - OPTIONS on a registered path that no layer handled gets an automatic
204 No Content with an
Allowheader. - A method mismatch on a registered path returns 405 Method Not Allowed
with an
Allowheader. (A path that matches no layer at all is a genuine 404.) - Both the 405 and auto-OPTIONS
Allowheaders advertiseHEAD(when aGETlayer exists) andOPTIONSalongside the explicitly registered methods. - Explicit
use()/all()/options()handlers andcors()are ordinary layers, so they run first and take precedence over the automatic OPTIONS/405 fallbacks. head()andoptions()registration helpers exist for method-specific handlers (makeRegister('HEAD'/'OPTIONS', false)). An explicithead()wins over the GET→HEAD fallback when registered first; an explicitoptions()wins over the automatic 204.
No open issues are currently tracked here.
Resolved items (no longer open):
-
Response validation— implemented:apiBuilder(service, { responses: true })validates each handler's return against the route's declaredresponses['200']schema (default off). A mismatch is a server-contract breach →500. The builder's optional second argument (ApiBuilderOptions) also makes request validation default-on ({ requests: false }cancels it) and overrides the legacyservice.validatefield when present. -
Cookie reading/writing— Signed cookies are fully implemented: HMAC-SHA256 signing/verification viacreateRouter({ secret }).j:prefix handled on both read and write. -
Async error catching— Theinvoke()helper wraps every middleware call;ret instanceof Promisecatches async rejections and routes them toinvokeErrorHandler. -
Directory listing sort—writeIndexOf()now supports sortable columns via?C=N;O=Dquery params (directories always first). -
—async setup()not awaitedbuildModule()now awaits thePromisereturned bysetup()before the module is considered ready. -
No ordered error middleware / no error bubbling—router.error()registers an ordered, escapable error-middleware chain; unhandled errors fall back toonError()then bubble to the parent router viadone(err).apiBuilderadds aServiceDefinition.onErrorhook. The 4-argument arity hack was deliberately rejected (see §14). Async caveat from §3 still applies. -
—strictbody option not enforcedBodyOptions.strict(defaulttrue) rejects a bare top-level JSON primitive with400 Bad Request(readBodyAsJsoninmisc.ts, taggedFIX-10). See §4. -
— fixed:cors()arrayorigindoesn't grant access to any listed origincors()now matchesreq.headers.originagainst the array and echoes back only the matching entry via aresolveAllowOrigin()helper, omitting the header on no match. See §9.
// Router
createRouter(prefixOrOpts?: string | RouterOptions, opts?: RouterOptions): Router
// Static
serveStatic(root: string, opts?: StaticOptions): Middleware
serveFile(filepath: string, opts?: StaticOptions): Middleware
sendFile(filepath: string, req: RouterRequest, res: RouterResponse): void
mime: Mime // MIME type lookup object
// Body / misc
json(opts?: BodyOptions): Middleware
formData(opts?: BodyOptions): Middleware
formEncoded(opts?: BodyOptions): Middleware
raw(opts?: BodyOptions): Middleware
text(opts?: BodyOptions): Middleware
parseBody(opts?: BodyOptions): Middleware
streamFormData(req: RouterRequest, opts?: BodyOptions): AsyncGenerator<FormPartStream>
logger(opts?: Partial<LoggerOptions>): Middleware
cors(opts?: Partial<CorsOptions>): Middleware
// Middleware
compress(opts?: CompressOptions): Middleware
requestId(opts?: RequestIdOptions): Middleware
rateLimit(opts: RateLimitOptions): Middleware
cacheControl(opts?: CacheControlOptions): Middleware
csrf(opts?: CsrfOptions): Middleware
securityHeaders(opts?: SecurityHeadersOptions): Middleware
conditionalGet(): Middleware
// JWT
createJwtPlugin(userConfig?: Partial<JwtConfig>): JwtPlugin
createMapTokenStore(): TokenStore
// Git
gitHandler(opt: GitHandlerOptions): (req, res) => void
gitCreate(gitDirectory: string, opt: GitCreateOption): Promise<void>
// API builder
apiBuilder<TInstance>(service: ServiceDefinition<TInstance>): ApiRouter // throws on duplicate routes
defineController<TInstance>(c: ControllerDefinition<TInstance>): ControllerDefinition<TInstance>
// OpenAPI
describe<TInstance>(handler: ServiceMethod<TInstance>, meta: OperationMeta): ServiceMethod<TInstance>
openApiSpec<TInstance>(service: ServiceDefinition<TInstance>, opts: SpecOptions): OpenApiDocument
serializeSpec(doc: OpenApiDocument, format?: SpecFormat): string
DESCRIBE_META: symbol // metadata key used to attach OpenAPI annotations// Router
type Middleware = (req: RouterRequest, res: RouterResponse, next: NextFunction) => void
type NextFunction = (err?: unknown) => void
type ErrorHandler = (err: unknown, req: RouterRequest, res: RouterResponse) => void
type ErrorMiddleware = (err: unknown, req: RouterRequest, res: RouterResponse, next: NextFunction) => void
type MiddlewareArg = Middleware | Router | (Middleware | Router)[]
interface RouterOptions // secret, timeout, trustProxy
interface RouterRequest // extends http.IncomingMessage — adds ip, path, params, queries, cookies, json(), text(), formData()
interface RouterResponse // extends http.ServerResponse — adds send(), json(), status(), redirect(), cookie(), download(), type(), etag()
interface Router // the return type of createRouter()
interface RouteInfo // { method, path, stripPath } — returned by router.routes()
interface Layer // internal route entry
interface RouteBuilder // chainable .all/.get/.put/.post/.delete/.patch/.head/.options, returned by router.route(path)
interface CookieOptions
interface TlsOptions
// Body parsing
interface BodyOptions
type BodyTypeMatcher // string | string[] | (req) => boolean — overrides which Content-Type a parser matches
type VerifyFn // (req, res, buf, encoding) => void; throw to reject the body
interface LoggerOptions
interface FormPart // multipart part: { headers, content: Buffer }
type FormPartStream // { headers, stream: Readable }
interface CorsOptions
// Middleware
interface CompressOptions
interface RequestIdOptions
interface RateLimitOptions
interface CacheControlOptions
interface CsrfOptions
interface SecurityHeadersOptions
// JWT
interface JwtConfig // accessTokenSecret, alg, accessTokenPrivateKey/PublicKey, ...
interface JwtPlugin
interface UserRecord
interface TokenPayload
interface TokenStore
interface RefreshTokenRecord
// APIs
interface ApiError // { status?, message?, data? }
interface ApiContext<TUser = unknown, TState = Record<string, unknown>>
// { query, params, path, user?, state }
interface ServiceDefinition<TInstance>
interface ControllerDefinition<TInstance> // { prefix?, tags?, guards?, permission?, GET?, ... }
type Guard // (ctx, req) => void | object | Promise<...>
interface AuthBinding<TUser> // { authenticate?, check?, scheme?, permissionsExtension? }
interface ApiBuilderOptions // { validateRequests?, validateResponses?: boolean | 'warn' }
type ServiceMethod<TInstance>
type ServiceMethods<TInstance>
type RouteMap<TInstance>
// OpenAPI
interface OperationMeta // + guards?, permission? (v2)
interface OpenApiServiceMeta<TInstance>
type RouteOpenApi // Record<string, OperationMeta>
interface ControllerOpenApi // prefix?, tags?, permission?, GET?/POST?/PUT?/DELETE?/PATCH? (RouteOpenApi)
interface ServiceOpenApi // controllers?, guards?, auth?, validate?, GET?/POST?/... (RouteOpenApi) — spec-only counterpart of ServiceDefinition
type OpenApiSource // ServiceDefinition<any> | ServiceOpenApi — accepted by openApiSpec()
interface SpecOptions // { title, version, basePath?, schemas?, ... }
type SpecFormat // 'json' | 'yaml'
interface OpenApiDocument // components may include securitySchemes (v2)
// Git
interface GitHandlerOptions# Build both ESM (tsc) and CJS shim (esbuild) → dist/
npm run build
# Remove all build artefacts (run before a clean build)
npm run clean
# Run all tests
npm test
# Run all tests with native coverage (src/ only)
npm run test:coverage
# Run specific test file (no build needed — tsx handles it)
node --import tsx --test tests/router.test.ts
# Install devDependencies only (no prod deps to install)
npm installThe dist/ directory is the published artifact. Always run npm run build
before checking that exported types match expectations.
npm run build performs two steps:
-
tsc— compilessrc/withtsconfig.json(module: NodeNext) todist/. Produces one.jsfile per source file (ESM, tree-shakeable), plus.d.tsand.d.ts.mapdeclarations. -
node scripts/build-cjs.cjs— runs esbuild via its Node.js API to produce a single bundled CJS file atdist/cjs/index.js, then writesdist/cjs/package.json({"type":"commonjs"}).
esbuild is used for the CJS step (not tsc) because tsc in CommonJS mode
does not support import ... with { type: 'json' } syntax, which is required
for the mimetypes.json import in src/static.ts.
The package.json exports field routes import consumers to dist/index.js
and require consumers to dist/cjs/index.js. The legacy "main" field
also points to dist/cjs/index.js for tools that do not read exports.
tsconfig.cjs.json exists for type-checking only (--noEmit); it is not
used by the build script.
- Write source in
src/with full JSDoc (all public exports must be documented) - Export from
src/index.ts(value + type) - Write tests in
tests/<module>.test.tsusing real HTTP servers - Build and verify
dist/compiles without errors - Update
README.md(user-facing documentation)
The framework deliberately avoids Express's 4-argument arity sniffing (fragile
under minifiers, ...args, and wrappers). Instead, error middleware is
registered explicitly via router.error((err, req, res, next) => …) — err
first, signalling the error channel. These form an ordered chain that, when
exhausted, falls back to the single onError() handler and then bubbles to
the parent router via done(err). See §3 "Error channel" for the full
cascade. apiBuilder layers on top with a ServiceDefinition.onError hook
(see §7).
All res.* helpers are attached once in updateHttpObjects. If you call
createRouter methods on a raw http.IncomingMessage/http.ServerResponse
without going through the router listener, these helpers won't be available.
The cors() middleware only sets Access-Control-Allow-Origin when
req.headers.origin is present. This means it won't set the header for
non-browser fetch calls that don't include an Origin header. For OPTIONS
preflight, it always responds and calls return (does not call next()).
router.listen() returns the underlying http.Server (or https.Server)
instance, making it possible to perform graceful shutdown, attach error
listeners, or discover the OS-assigned ephemeral port:
// Graceful shutdown on SIGTERM
const server = router.listen(3000, () => console.log('Listening'));
process.on('SIGTERM', () => server.close());
// Discover the OS-assigned ephemeral port (useful in tests)
const server = router.listen(0, () => {
const { port } = server.address() as AddressInfo;
console.log(`Listening on port ${port}`);
});The test files use this when they need a bare server without a custom done
callback. Helpers that wrap router.listener with a custom done function (e.g.
to inject session data or send a 'next() called' fallback body) still create
their own http.createServer() internally.