bakeway
v0.1.8
Published
A TypeScript web framework for Bun
Readme
Bakeway
A TypeScript web framework for Bun with no external runtime libraries. Bakeway handles ordinary Request objects and returns Response objects; your application owns its server, startup, shutdown, WebSockets, authentication and services.
The npm package is bakeway; its source repository directory is Bakeway. Version 0.1.8 targets Bun 1.4.2 and TypeScript 7.0.2 with strict ESM/bundler resolution. Compiled ESM and complete declarations are included. Import from the package root.
bun add [email protected]Existing users of wizepal-server can install bakeway and change their imports to "bakeway". The exported API is unchanged.
Version 0.1.8
This patch fixes Redis credential URL encoding, preserving literal % characters and percent-looking text in raw usernames/passwords. It adds a 169-case Bun unit suite, strict compile-only contract checks, and test gates before release packaging. Native Redis protocol checks verify credential round-tripping in the installed package; a separate opt-in integration test supports checking your own Redis connection.
Public APIs and runtime dependencies are unchanged. See Unit tests for commands and the documented string-date coverage limitation.
Application and controllers
import { Application, Controller, Service, Lib, type Routes, type ApplicationOptions } from "bakeway";
class GreetingService extends Service {
greet() {
return "Hello";
}
}
class LocalLib extends Lib {
static timestamp() {
return this.Date.Now().toISOString();
}
}
class APIController extends Controller {
private service = new GreetingService();
GET() {
this.get.set("hello", this.hello.bind(this));
this.get.set("text", () => new Response("Hello", { status: 200 }));
}
POST() {}
private hello(request: Request) {
const name = Controller.GetQueryParams(request).get("name");
return Controller.Response({ message: this.service.greet(), name, at: LocalLib.timestamp() }, true, 201);
}
}
const routes: Routes = { api: APIController };
const options: ApplicationOptions = {
surfaces: [
{
async handle(request) {
return new URL(request.url).pathname === "/health" ? new Response("ok") : null;
},
},
],
middlewares: [
{
async handle(request, next) {
const response = await next();
response.headers.set("x-example", "yes");
return response;
},
},
],
};
const application = new Application(routes, options);
const response = await application.handle(new Request("https://example.test/api/hello?name=Ada"));Surfaces run first in configured order. A handled surface bypasses middleware and routing, so each surface owns any required authentication. On a miss, middleware wraps dispatch, first middleware outermost; next() may run only once. Result conversion finishes before middleware resumes.
Routes and option lists are snapshotted at construction. Each Application lazily constructs and reuses its own controllers. Registration runs synchronously after the concrete constructor and field initializers finish. Failed construction/registration is not cached. Controllers are shared across requests within an Application: keep request-specific data in local variables.
GET/POST maps use the path after the controller prefix as the action name (including any remaining slashes). Missing controllers/actions return 404; a known action with an unsupported method returns 405 with Allow. Other HTTP methods can be handled by surfaces.
Controller.Response(data, status = true, http_status?) creates an envelope. status accepts a boolean or number and is distinct from the HTTP status. Application serializes the entire envelope, including http_status when provided, using HTTP http_status ?? 200. Native Responses pass through unchanged. Subclasses may override protected toResponse(result, request) and onError(error, request) with synchronous or asynchronous implementations. Errors in awaited surfaces, middleware, dispatch and conversion reach onError. A failing error hook gets a direct generic 500 fallback.
Controller factories
Routes accept either a zero-argument controller constructor or a synchronous factory returning a Controller subclass:
import { Application, Controller, type Routes } from "bakeway";
class Sessions {
readonly active = 0;
}
class GatewayController extends Controller {
constructor(private readonly sessions: Sessions) {
super();
}
GET() {
this.get.set("count", () => Controller.Response(this.sessions.active, true));
}
POST() {}
}
class HealthController extends Controller {
GET() {
this.get.set("check", () => new Response("ok"));
}
POST() {}
}
const sessions = new Sessions();
const routes = {
gateway: { create: () => new GatewayController(sessions) },
health: HealthController,
} satisfies Routes;
const application = new Application(routes);ControllerConstructor, ControllerFactory, RouteDefinition and Routes are exported types. Async factories and non-controller results are rejected by TypeScript. Each Router snapshots route entries and factory function references, creates controllers lazily on the first request for that route, runs POST/GET registration after construction, and caches only successfully initialized controllers per route. Construction, factory and registration failures propagate through the existing error boundary and may be retried on subsequent requests. Each Router/Application owns its cache; return a new controller from each factory invocation to preserve controller isolation. Dependencies captured by factories remain application-owned and are never disposed of or managed by the framework.
Low-level Router
import { Router } from "bakeway";
const router = new Router(routes);
const result = await router.call(new Request("https://example.test/api/hello"));
const query = Router.GetQueryParams(new Request("https://example.test/?key=value"));Each Router owns a route snapshot and independent controller cache. It returns the raw controller envelope or Response and propagates errors. Application supplies the surfaces, middleware, conversion and HTTP error boundary. Router has no singleton, route replacement API or Router injection option.
Service helpers and utilities
Service.validateRequestBody<T>(request, required_keys) reads, parses and checks required keys. An instance convenience method delegates to it. Service.parseJsonRequestBody(rawBody) first tries JSON, then retains the permissive normalization fallback (smart quotes, comments, newlines and whitespace). Service.validateRequestBodyObject<T> checks nil and key presence; these checks are not value/schema validation.
Service.validateSignedRequestBody<T>(request, required_keys, verifier) passes the original raw text and Headers to your verifier before parsing, and returns { body, rawBody }. Verifier/read failures propagate unchanged. Expected parser/body failures use framework OpsError codes.
Service.ExtractApiKey(request, body?) selects a nonblank exact Bearer header value, then a supplied body.api_key, then the URL query parameter api_key (for example, ?api_key=your-key). Header/body checks retain their existing Guards classification. Query values are URL-decoded and trimmed; blank values are ignored, and the first value is used when the parameter is repeated. It does not read the body or validate credentials. Missing input throws service:missing-api-key (default HTTP 400).
Supplied Guards, Lib, E_IS, NonNullableType, JsonValue and JsonObject are exported unchanged. In particular, Guards classifies numeric strings as numbers and IsType checks presence of keys rather than their value types.
Singleton utility
import { Singleton } from "bakeway";
class Settings extends Singleton {
private constructor(public readonly region: string) {
super();
}
static Open(region: string): Settings {
return this.GetInstance(region);
}
}
const settings = Settings.Open("eu");
Settings.Open("us") === settings; // true; region remains "eu"
Settings.RemoveInstance();
const replacement = Settings.Open("us"); // a new instanceThe existing droplet-bot implementation is exported as Singleton, with Constructor and ISingletonConstructor types. Each concrete subclass has its own cached instance. GetInstance() supports no argument or one argument; only the first successful construction uses that argument. RemoveInstance() clears that subclass's cache without disposing the old object. Failed construction is not cached. Argument types retain the original permissive API; use a typed static wrapper such as Open to enforce your constructor's requirements.
Databases and Redis
Database extends Singleton: each concrete database subclass has one cached instance. Connection(...args) infers the public constructor's argument tuple and returns that subclass. A no-argument constructor needs no arguments; a configured constructor requires its arguments on every call.
import { Database, RedisDatabase, type RedisOptions } from "bakeway";
class LocalDatabase extends Database {
protected async open() {
/* establish connection */
}
protected async close() {
/* release connection */
}
}
const local = await LocalDatabase.Connection();
const options = {
host: "localhost",
port: 6379,
// username: "default",
// password: "...",
// tls: true,
} satisfies RedisOptions;
const redis = await RedisDatabase.Connection(options);
await redis.client.set("greeting", "hello");
const greeting = await redis.client.get("greeting"); // string | null
await redis.disconnect();The base owns connect() and disconnect() and their connected/processing state; subclasses implement protected open() and close() hooks. Concurrent connection calls share one attempt and all await readiness. Failure clears the pending attempt so a later call can retry. Disconnect waits for an active connection attempt to settle. Reconnecting reuses the same database instance. Use Connection() to initialize database singletons, rather than the inherited raw GetInstance().
Configuration is fixed for the lifetime of a cached instance. Redis snapshots its connection URL and accepts equivalent options on subsequent calls; changed options throw without reporting credentials. Generic databases compare constructor arguments using Object.is (object identity); reuse those objects without mutating them, or override matchesConnectionArguments(args, initial) for custom comparison. Disconnect before calling inherited RemoveInstance() to reset configuration; removal alone does not close resources.
Redis requires host and numeric port; username/password are optional and safely URL-encoded, and tls defaults to false (true uses rediss). Environment-variable reading stays in the application. client exposes Bun's native RedisClient and becomes available during connection; await Connection() or connect() before issuing commands. The adapter awaits the native connection handshake, closes failed clients, and reports the client's current connection status. It does not log credentials or read application environment settings.
The package includes @types/bun for fully typed native APIs. These are declaration dependencies; Redis itself is provided by Bun, with no external runtime client library.
Typed local errors
import { OpsError, Application, type OpsErrorContext, type ErrorCode, type ErrorDomain, type ErrorType, type ErrorArguments } from "bakeway";
const gatewayErrors = {
"gateway:session-not-found": (context: OpsErrorContext & { sessionId: string }) => `Session ${context.sessionId} was not found.`,
};
const GatewayError = OpsError.extend(gatewayErrors);
type GatewayCode = ErrorCode<typeof gatewayErrors>;
type GatewayDomain = ErrorDomain<typeof gatewayErrors>;
type GatewayErrorType = ErrorType<"gateway", typeof gatewayErrors>;
type AllErrorTypes = ErrorType<undefined, typeof gatewayErrors>;
type SessionArguments = ErrorArguments<"gateway:session-not-found", typeof gatewayErrors>;
class GatewayApplication extends Application {
protected override onError(error: unknown, request: Request) {
if (error instanceof GatewayError && error.code === "gateway:session-not-found") {
return Response.json({ message: "Session not found" }, { status: 404 });
}
return super.onError(error, request);
}
}
throw new GatewayError("gateway:session-not-found", { sessionId: "example" });Use the const form when defining only a catalog. Its constructor infers the code from the first argument, so a required context remains directly accessible:
const error = new GatewayError("gateway:session-not-found", { sessionId: "example" });
error.context.sessionId; // stringUse a subclass when adding behavior. An ordinary subclass has one instance type; use is(code) to narrow both its code and context, including after instanceof in an error handler:
class LocalError extends OpsError.extend(gatewayErrors) {
#origin = "gateway";
origin() {
return this.#origin;
}
}
const error = new LocalError("gateway:session-not-found", { sessionId: "example" });
if (error.is("gateway:session-not-found")) {
error.context.sessionId; // string
}
function handle(error: unknown) {
if (error instanceof LocalError && error.is("gateway:session-not-found")) {
return error.context.sessionId;
}
}is(code) is available on both forms and compares the instance's code. LocalError.extend(...) uses real superclass construction: LocalError's constructor, field initializers and private fields are initialized for derived instances. The most-derived catalog supplies the message while inherited codes remain supported.
Message-function arguments determine required, optional or absent context. Unknown codes and missing required context are compiler errors. GetMessage uses the class's own catalog. Instances retain readonly code and typed context, standard cause, and instanceof OpsError identity. Catalogs are snapshotted and independent; inherited codes cannot be replaced. Helpers take the additional definitions object type, include framework definitions, and default to framework-only meanings when additions are omitted.
Only known HTTP framework codes receive default mappings. Expected Service failures map to 400, missing routes/actions to 404, and method mismatch to 405. Other exceptions, configuration-loader errors and consumer errors receive the fixed application:internal-error 500 envelope unless explicitly mapped by your Application. Diagnostic messages, causes and context are never copied into default responses.
Unit tests
bun run test # Isolated Bun tests and strict compiler fixtures
bun run test:unit # Runtime tests only
bun run typecheck:tests # Runtime/helper types and compile-only contracts
TZ=UTC bun test --isolate ./tests/unit/database
bun run test:unit --randomize # Verify order independence
bun run test:unit --coverage # Informational coverage; no percentage gateTests live under tests/unit/<subject>/. They exercise in-memory HTTP behavior, service parsing/authentication, errors, singleton/database lifecycles, utilities and public exports. Redis unit tests use a controlled internal factory and open no sockets. The canonical runtime command uses --isolate to prevent module mocks leaking across files; use it instead of bare bun test for the full suite. Adjacent *.types.ts files compile strictly and never execute. Neither tests nor their helpers/configuration enter the package archive.
RedisOptions.username and password accept raw credentials. Do not URL-encode them before passing them; Bakeway encodes them when constructing the connection URL, including literal % and %2F text.
The existing string-input branch of Lib.Date.AddTimeFromDate ignores the offset. That behavior remains an explicitly disclosed gap pending a separate decision; tests cover its Date-input branch without treating the string behavior as correct.
Redis connection integration test
The unit suite uses fake Redis clients and requires no credentials. To explicitly check a real Redis endpoint, set REDIS_HOST, REDIS_PASSWORD, REDIS_PORT, and REDIS_USERNAME in your ignored local .env, then run:
bun run test:integration:redisThis separate Bun integration test loads .env, connects through RedisDatabase, sends PING, and disconnects. It does not read, write, or delete application keys. TLS is enabled by default; set REDIS_TLS=false only for a server intentionally configured without TLS. There is no automatic plaintext fallback. Native error details are suppressed to keep credentials out of test output.
The command explicitly sets BAKEWAY_REDIS_INTEGRATION=1. Without that opt-in, the integration case is skipped even if discovered by bare bun test. It is excluded from the regular bun run test / test:unit paths and is not a release prerequisite. Passing this check proves connectivity for the supplied configuration, not arbitrary credential encoding, failure recovery, or application integration.
Development and release
bun install
bun run test
bun run typecheck
bun run build
bun run release --dry-run
bun run release --publish /absolute/path/to/bakeway-0.1.8.tgzDry-run requires the isolated unit suite and strict test compiler to pass before building or packing. It then checks archive contents, installed declarations and runtime in a disposable consumer, including native Redis authentication with synthetic percent-containing credentials against a local protocol fixture. The optional live Redis check is separate. Publication rechecks that exact archive and its recorded integrity, uploads it without rebuilding, then compares registry integrity and checks an unauthenticated exact-version installation. The scripts do not change versions or run Git commands. They target public [email protected] on registry.npmjs.org with the normal latest tag.
For publication, supply NPM_TOKEN in the caller environment or ignored local .env (see .env.example). The caller environment takes precedence. Only that key is read; dotenv content is not executed. Authentication uses a registry-scoped temporary npm configuration removed on exit. Never commit credentials. Internal design documents and authentication files are excluded from the package.
License: UNLICENSED. Public npm availability does not grant an open-source license.
