Tiny contracts for sturdy Node APIs.
Cricket gives Node APIs the backend shape that stays pleasant as the app grows: plain JavaScript, Zod contracts, predictable domain files, thin routes, boring services, first-class jobs, OpenAPI generation, and a normal Node entrypoint.
No model instances. No hidden mutation. No ORM lifecycle. Your app passes plain objects around, composes functions, and keeps side effects at the edges.
pnpm add @robdel12/cricketStart with the complete structured app and agent contract:
pnpm cricket init .
pnpm cricket check api/index.js
pnpm cricket inspect api/index.js
pnpm cricket docs api/index.js --out openapi.jsoninit creates the folders Cricket expects, adds the Cricket section to
AGENTS.md, and installs a small local skill suite under .agents/skills/:
cricketteaches the framework shape, domain pattern, and change flow.cricket-jobsteaches jobs, schedules, retries, workers, and the ledger.cricket-observabilityteaches logging, tracing, lifecycle, and debugging.cricket-testingteaches HTTP-boundary tests, worker-boundary job tests, and Cricket test state.
That skill suite is part of Cricket's docs surface. It exists because Cricket is meant to be easy for agents to use correctly while humans drive the product decisions.
init app and init agents remain available as focused commands for existing
projects, but init . is the normal adoption path.
In this repo, the canonical scaffolded guidance lives in
src/templates/agents/, including each skill's real SKILL.md file.
- Keep data plain: objects in, objects out.
- Keep side effects at clear boundaries: services, handlers, jobs, middleware, migrations, and external clients.
- Keep contracts at real edges: requests, responses, source payloads, jobs, and database rows.
- Keep definitions stable: app, endpoint, rule, model, serializer, normalizer, and job contracts cannot drift after construction. Cricket owns immutable contract structure without freezing caller-owned runtime values.
- Keep domain files predictable. Agents should be able to guess where behavior lives before they open the repo.
- Keep framework behavior visible. Cricket provides runtime shape; your app defines product behavior, auth policy, data policy, worker entrypoints, health checks, and deployment.
api/
index.js app entrypoint and Cricket wiring
domains/ product API domains
middleware/ request middleware
services/ app-wide capabilities
workers/ background worker entrypoints
migrations/ app database migrations
dev/ local-only support
Use domains/ for product behavior. Use middleware/ for HTTP edge work such
as auth extraction, CORS, request IDs, raw webhooks, and frontend fallbacks. Use
services/ for narrow shared capabilities like mail, storage, payments, caches,
and external clients. Use workers/ for background entrypoints that start
Cricket workers. Use dev/ only for local support.
If code affects product behavior, put it in a domain, app service, worker, middleware, or migration. Avoid generic junk drawers.
Cricket requires a domains contract by default. Structured apps cannot
register endpoints, jobs, or models directly on defineCricketApp; those
contracts must come from domains.
Existing or embedded applications can temporarily opt out:
export let app = defineCricketApp({
architecture: 'manual',
endpoints: legacyEndpoints
});Manual architecture is an escape hatch, not a second recommended application
shape. Treat it as visible tech debt while migrating an existing app. Cricket
labels it in inspect and check; remove it once product contracts live in
domains. Manual mode cannot be mixed with domains, so the final cutover is
deliberate and direct rather than a permanent hybrid architecture.
Use one folder per domain.
api/domains/project/
schema.model.js row contracts and visibility
input.validations.js request/source/service input schemas
source.normalizers.js outside payload projections
output.serializers.js API output projections
domain.service.js data and product operations
access.rules.js auth, existence, ownership, business guards
http.routes.js endpoint contracts
*.jobs.js background job contracts
behavior.test.js HTTP and worker-boundary tests
The folder is the domain. Cricket auto-loads direct domain-local files by
suffix, such as *.model.js, *.routes.js, and *.jobs.js. Optional files
stay optional, and filenames can describe the slice they contain.
Put the app contract in api/index.js.
import { defineCricketApp, startCricketApp } from '@robdel12/cricket';
export let app = defineCricketApp({
name: 'Project API',
version: '1.0.0',
prefix: '/api',
logger: {
service: 'project-api',
level: process.env.LOG_LEVEL ?? 'info'
},
database: {
client: 'pg',
defaultEnvironment: 'development',
environments: {
development: {
connection: process.env.DATABASE_URL
},
test: {
client: 'sqlite3',
connection: {
filename: ':memory:'
},
useNullAsDefault: true
},
production: {
connection: process.env.DATABASE_URL
}
}
},
domains: './domains',
async setup({ db }) {
return {
services: {
projects: createProjectService({ db })
}
};
}
});
if (process.env.NODE_ENV !== 'test')
await startCricketApp(app, {
port: process.env.PORT || 3000,
main: import.meta.url
});setup returns undefined or one explicit object with dependencies,
services, and cleanup. Cricket adds its configured db to dependencies;
apps must not return a second db.
Capabilities follow the runtime phase that owns them. Setup receives the app,
database, lifecycle, logger, and a no-op startup trace. Domain services, the
app service composer, and middleware initialization receive dependencies,
lifecycle, and logger before requests exist. Request context, rules, and
handlers receive the request logger and trace plus services, lifecycle, and
setup dependencies. Job run and failure handlers receive services, lifecycle,
logger, trace, jobs, and progress. Recovery receives execution evidence, time,
logger, and trace because it returns a pure decision rather than doing product
work. Shutdown hooks receive the assembled runtime, including dependencies,
services, lifecycle, and logger.
A package can export named Cricket domains for an app to compose with its own domain folder. Keep Cricket as a peer dependency so the package and app use the same Cricket contracts:
// In the package, such as @acme/super-admin:
import {
defineCricketPlugin,
definePluginSchema,
z
} from '@robdel12/cricket';
let UserPage = z.object({
items: z.array(z.object({
id: z.string(),
email: z.email(),
name: z.string().nullable(),
state: z.enum(['active', 'suspended'])
})),
nextCursor: z.string().nullable()
});
let superAdminSchema = definePluginSchema({
services: {
adminAccess: {
requireAdmin: {
input: z.unknown(),
output: z.boolean()
}
},
userSupport: {
listUsers: {
input: z.object({ search: z.string().optional() }),
output: UserPage
}
}
}
});
export let superAdminPlugin = defineCricketPlugin({
name: 'super-admin',
schema: superAdminSchema,
domains: [userSupportDomain]
});The app composes that descriptor beside its filesystem domains and maps its own data into the shape declared by the plugin:
import { defineCricketApp } from '@robdel12/cricket';
import { superAdminPlugin } from '@acme/super-admin';
export let app = defineCricketApp({
domains: './domains',
plugins: [superAdminPlugin],
services({ services }) {
return {
...services,
adminAccess: {
requireAdmin(request) {
return request.headers['x-admin'] === 'true';
}
},
userSupport: {
async listUsers({ search }) {
let rows = await services.productUsers.searchUsers({ search });
return {
items: rows.map(row => ({
id: row.userId,
email: row.primaryEmail,
name: row.displayName,
state: row.suspendedAt ? 'suspended' : 'active'
})),
nextCursor: null
};
}
}
};
}
});defineCricketPlugin copies the domain containers and freezes their structure.
Built contracts, schemas, and functions keep their identity. Cricket loads the
app's domain folder first, then adds plugin domains in the order listed. They
use the same HTTP, worker, inspect, and OpenAPI paths as app domains.
definePluginSchema describes the app service methods a plugin needs. Each method
declares one Zod input and output schema. The app provides those functions in
its service registry; Cricket checks that they exist when it builds the
runtime and validates each call. Plugin routes can use the same schemas for
their request and response contracts when those shapes match. The app maps its
own rows into the plugin's shared shape. If an app rule returns an authenticated
actor or trusted scope, pass those facts as explicit adapter input; do not take
trusted scope from request filters.
Plugins contribute domains only. They do not discover files or package paths, register middleware or lifecycle hooks, or load migrations. A plugin model's table still needs an app-owned migration. The app owns authentication and product policy. It can connect plugin endpoints to app services for product data and actions, so the package does not need a shared user, moderation, or resource table.
Cricket plugins are backend packages; they do not serve CSS or frontend files. If a plugin includes React UI, export its components and stylesheet from a frontend package and import them through the app's normal build:
import { SuperAdminRoutes } from '@acme/super-admin-ui';
import '@acme/super-admin-ui/styles.css';The app chooses where the UI appears and connects it to its auth and API
client. Keep CSS file paths out of defineCricketPlugin; Cricket does not own
the frontend build.
See examples/plugin-composition/ for a
filesystem domain combined with a package-style plugin, including user support
actions and a separately paged moderation adapter.
Models describe durable rows and default visibility:
import { defineModel, field, z } from '@robdel12/cricket';
export let Project = defineModel({
name: 'Project',
table: 'project',
row: {
id: field.public(z.uuid()),
owner_id: field.private(z.uuid(), { sensitive: true }),
slug: field.public(z.string()),
name: field.public(z.string())
}
});Validations are reusable Zod schemas for data entering a boundary:
import { z } from '@robdel12/cricket';
export let ProjectCreateInput = z.object({
slug: z.string().min(3),
name: z.string().min(1)
});
export let ProjectInsert = z.object({
id: z.uuid(),
owner_id: z.uuid(),
slug: z.string().min(3),
name: z.string().min(1)
});
export let ProjectParams = z.object({
slug: z.string().min(3)
});Normalizers turn outside payloads into app shapes. Serializers turn domain data into API output shapes. Both should be pure.
import { defineSerializer, pickFields } from '@robdel12/cricket';
import { Project } from './schema.model.js';
export let serializeProjectPublic = defineSerializer({
name: 'project.public',
output: Project.public,
serialize: pickFields(['id', 'slug', 'name'])
});Services do data and product work without knowing about HTTP:
import { randomUUID } from 'node:crypto';
import { createKnexRepository } from '@robdel12/cricket';
import { Project } from './schema.model.js';
import { ProjectInsert } from './input.validations.js';
export function createProjectService({ db }) {
let projects = createKnexRepository({
db,
model: Project,
insert: ProjectInsert
});
return {
async createForUser({ userId, slug, name }) {
return await projects.insert({
id: randomUUID(),
owner_id: userId,
slug,
name
});
}
};
}Rules answer whether the request can continue. Routes compose validation, rules, handlers, serializers, response contracts, and docs metadata.
import { created, defineEndpoint, z } from '@robdel12/cricket';
import { Project } from './schema.model.js';
import { serializeProjectPublic } from './output.serializers.js';
import { ProjectCreateInput } from './input.validations.js';
import { requireUser, slugAvailable } from './access.rules.js';
export let createProject = defineEndpoint({
method: 'post',
path: '/projects',
body: ProjectCreateInput,
rules: [
requireUser,
slugAvailable
],
response: z.object({
success: z.literal(true),
project: Project.public
}),
async handler({ input, services, user }) {
let project = await services.projects.createForUser({
userId: user.id,
...input.body
});
return created({
success: true,
project: serializeProjectPublic(project)
});
}
});Definition builders reject unknown app and endpoint options so misspelled wiring fails immediately instead of becoming unused metadata.
API versioning is endpoint-owned and optional. A shared immutable version
family names the request header, current contract, pinned default, and supported
versions. It is not registered on defineCricketApp.
import { defineApiVersions } from '@robdel12/cricket';
export let sdkVersions = defineApiVersions({
name: 'project.sdk',
header: 'Project-Version',
clientHeader: 'Project-SDK-Version',
current: '2026-09-01',
default: '2025-11-15',
versions: {
'2025-11-15': {
deprecatedAt: '2026-09-01',
sunsetAt: '2027-09-01'
},
'2026-09-01': {}
}
});The endpoint's body, response, and responses define the base contract.
Historical entries contain only the compatibility work that endpoint needs.
Body normalizers must reuse the endpoint's body schema as their output;
Cricket parses it once and rejects skipped (null/undefined) results.
By default, the response schema validates both canonical data and current public output. When those shapes differ, pair a canonical schema with a current serializer:
let currentReport = defineSerializer({
name: 'report.current',
output: z.object({ id: z.string(), title: z.string() }),
serialize: value => ({ id: value.id, title: value.title })
});
let response = {
schema: ReportData, // Validated data needed by supported representations.
serializer: currentReport
};Use this definition in response or under a status in responses. Cricket
validates the handler result first, then runs exactly one serializer: the
historical override when present, otherwise the base serializer. Each serializer
validates its own public output. Fields removed by canonical validation stay
removed. OpenAPI describes the selected serializer's output, not ReportData.
A serializer requires a canonical Zod schema and must come from defineSerializer.
The current serializer belongs in the base response, not an apiVersions override.
Endpoint rules, including beforeBodyRules, and handlers receive the negotiated
apiVersion. Use it at that boundary to choose explicit data requirements before
calling a service. Services receive those requirements, not API versions. Field
selection and loading policy stay in the app; use schemas that require the facts
each selected response promises instead of making every field optional.
Keep these adapters pure and explicit. Their context is trusted app context; Cricket cannot prevent app code from intentionally loading or returning private data. Review every supported projection and test authorization through HTTP. Version deltas cover body and handler response shape only; params, query, rules, thrown framework errors, and transport behavior remain shared.
export let createProject = defineEndpoint({
method: 'post',
path: '/projects',
apiVersions: sdkVersions({
'2025-11-15': {
body: normalizeLegacyProjectCreate,
responses: {
201: serializeLegacyProject,
202: serializeLegacyQueuedProject
}
}
}),
body: ProjectCreateInput,
responses: {
201: ProjectResponse,
202: QueuedProjectResponse
},
rules: [requireUser],
handler: createProject
});Use apiVersions: sdkVersions() on an unchanged endpoint when it should still
negotiate the family and report usage. Omit apiVersions entirely when an
endpoint should ignore version headers. Unsupported or ambiguous values fail
with a bounded bad request; unknown raw values are not logged or echoed.
Use dedicated version headers, distinct from credentials and transport headers.
Version identifiers are exact visible ASCII strings without commas (128 chars
maximum); the optional client version is bounded telemetry, not trusted identity.
Cricket adds the effective version and Vary response headers, and attaches
the selected family/version to route logs and traces. The optional client header
is bounded telemetry only and never selects the API contract. Generate an exact
OpenAPI contract with:
pnpm cricket docs api/index.js \
--api-version project.sdk=2026-09-01 \
--out openapi.jsonDeprecation and sunset dates announce policy; they do not disable versions on a timer. To retire a version, remove its family entry and endpoint deltas, and update the pinned default if necessary. Requests naming that version then fail with 400. Regenerate each published version's OpenAPI after changing contracts.
When compatibility is no longer needed, remove the endpoint's apiVersions
option and eventually delete the unused family definition. Old clients may
continue sending the now-unused header; ordinary endpoints ignore it.
Bare values returned by handlers, middleware, and fallbacks are response bodies.
Only Cricket's response functions control HTTP transport details, so a domain
object containing fields such as status, headers, or redirect stays a
normal JSON body.
Request validation failures may include useful issues for API clients. Response,
serializer, and normalizer contract failures remain detailed in logs and
onError, but their HTTP response is a redacted internal error.
import {
ok,
redirect,
respond,
withCookies,
withHeaders,
withResponseCleanup
} from '@robdel12/cricket';
return withHeaders(respond(202, {
queued: true
}), {
'Retry-After': '5'
});
return withCookies(ok({
signedIn: true
}), [{
name: 'session',
value: session.id,
options: {
httpOnly: true,
secure: true
}
}]);
return redirect('/projects', 303);
return withResponseCleanup(
withHeaders(ok(stream), {
'Content-Type': 'text/event-stream'
}),
() => stream.destroy()
);Endpoints that accept uploads can opt into bounded multipart parsing:
let upload = defineEndpoint({
method: 'POST',
path: '/uploads',
beforeBodyRules: [requireProjectToken],
multipart: {
maxBytes: 50 * 1024 * 1024,
maxFiles: 10,
maxFileBytes: 50 * 1024 * 1024,
maxFields: 50
},
handler({ request }) {
return created({
fields: request.body,
files: request.files.map(file => ({
fieldName: file.fieldName,
originalName: file.originalName,
mimeType: file.mimeType,
path: file.path,
size: file.size
}))
});
}
});Multipart fields are plain values; repeated fields become arrays. Files are streamed to temporary
files and exposed through request.files during the handler. Cricket removes those files after the
endpoint returns, including when parsing or validation fails. Set maxBodyBytes, maxFileBytes,
maxFieldBytes, maxFiles, and maxFields for the upload's actual limits.
Use beforeBodyRules for authentication and other inexpensive checks that must pass before Cricket
accepts an upload. These rules receive request headers, route parameters, and app context, but no
parsed body. Their returned facts are available to the endpoint's remaining rules and handler.
Use ok(body) for 200, created(body) for 201, and respond(status, body) for
other statuses. Compose withHeaders, withCookies, and
withResponseCleanup around an explicit response. Streams and buffers remain
ordinary body values; the helpers add transport intent without wrapping them in
a mutable response builder.
Cricket uses Knex as the database path. It creates one db handle for the
runtime, passes it through app capabilities, and destroys it during cleanup.
Migrations live in api/migrations/ by convention:
pnpm cricket migrate make api/index.js create_projects
pnpm cricket migrate latest api/index.js
pnpm cricket migrate status api/index.js
pnpm cricket migrate rollback api/index.jsCricket does not run migrations on server start, design tables, hide data policy, or replace Knex. It makes the database contract visible from the same app definition your server uses.
Jobs are Cricket contracts for background work. Use them when work needs validated input, immutable envelopes, explicit queue coordination, scheduled execution, recovery, failure handling, logs, traces, progress, and the same services your HTTP handlers use.
import { z } from '@robdel12/cricket';
import {
concurrency,
createCricketJobs,
cronSchedule,
defineJob,
jobFailure,
redisQueue,
retry
} from '@robdel12/cricket/jobs';
export let generateReport = defineJob({
name: 'reports.generate',
input: z.object({
reportId: z.string(),
accountId: z.string()
}),
context: z.object({
priority: z.number().int().default(0)
}).default({}),
queue: redisQueue({
name: 'reports',
idempotencyKey: ({ input }) => input.reportId,
priority: ({ context }) => context.priority
}),
concurrency: [
concurrency.global({
key: 'reports:rendering',
limit: 4
}),
concurrency.partition({
key: ({ input }) => `account:${input.accountId}`,
limit: 1
})
],
retry: retry.exponential({
attempts: 3,
delayMs: 2_000,
maxDelayMs: 60_000
}),
recover({ run, logs, progress }) {
if (run.heartbeatAgeMs > 2 * 60_000)
return {
action: 'retry',
reason: {
code: 'heartbeat_stale',
message: 'worker heartbeat is stale'
}
};
if (run.ageMs > 5 * 60_000 && !logs.seen('report.started', { within: '5 minutes' }))
return {
action: 'retry',
reason: {
code: 'report_never_started',
message: 'report job never started'
}
};
if (run.ageMs > 10 * 60_000 && !progress.seen({ within: '10 minutes' }))
return {
action: 'retry',
reason: {
code: 'report_not_advancing',
message: 'report job stopped reporting progress'
}
};
return { action: 'continue' };
},
failure: jobFailure({
async exhausted({ input, failure, services }) {
await services.reports.markFailed({
reportId: input.reportId,
reason: failure.message
});
}
}),
schedule: cronSchedule({
key: 'daily-reports',
cron: '15 4 * * *',
timezone: 'America/Chicago',
input: ({ scheduledFor }) => ({
reportId: `daily:${scheduledFor.slice(0, 10)}`,
accountId: 'system'
})
}),
async run({ input, logger, services, trace, progress }) {
logger.info('report.started', {
reportId: input.reportId
});
await progress.update({ current: 1, total: 1 });
return trace.span('reports.generate', {
accountId: input.accountId
}, () => services.reports.generate(input));
}
});Producer entrypoints enqueue without starting a worker:
let producer = await createCricketJobs({
jobs: [generateReport],
queues: {
redis: {
url: process.env.REDIS_URL
}
}
});
await producer.jobs.enqueue(generateReport, {
reportId,
accountId
});Worker entrypoints execute jobs:
import { startCricketWorker } from '@robdel12/cricket/jobs';
import { app } from '../index.js';
import { generateReport } from '../domains/reports/reporting.jobs.js';
let worker = await startCricketWorker(app, {
queues: {
redis: {
url: process.env.REDIS_URL
}
},
jobs: [generateReport]
});
let shutdown = new AbortController();
process.once('SIGTERM', () => shutdown.abort());
try {
await worker.run({
signal: shutdown.signal
});
} finally {
await worker.cleanup();
}In domain architecture, a worker may execute all app jobs or select a subset, but every selected job must belong to one of the app's resolved domains, including plugin domains. Manual apps may register jobs at the worker boundary while they migrate that ownership.
Choose the queue deliberately. Production producers and workers use
queues.redis or an app-provided queues.driver; tests opt into the in-memory
driver with queues.test: true. Cricket never silently turns a missing queue
configuration into an in-memory worker.
The built-in Redis client accepts redis:// and rediss:// URLs, including ACL
credentials and a numeric database path. rediss:// verifies certificates by
default; pass Node TLS options as queues.redis.tls when the deployment uses a
private CA. An app-provided client must implement duplicate() so blocking
wakeups never stall normal Redis commands. The built-in driver targets a
standalone Redis primary; Redis Cluster is not supported by the built-in
driver.
worker.run({ signal }) blocks on queue wakeups and the next delayed or cron
boundary. Enqueuing ready work wakes it immediately. Aborting the signal or
calling worker.cleanup() stops the wait without a polling interval.
Exponential retries use the job policy as execution behavior. The first retry
waits delayMs, each later retry doubles that delay, and maxDelayMs caps it.
The retry stays unclaimable until that calculated availability time, while the
original immutable envelope stays unchanged.
Queue policy travels with that immutable envelope. Claims prefer higher numeric priority among the ready work observed for that claim; equal priorities use creation time and then envelope ID for stable order. Work enqueued during a claim becomes eligible on the next claim.
Global concurrency limits shared work, while partition limits keep one account or tenant from consuming all capacity. A blocked partition does not prevent another partition from running. Drivers evaluate the resolved envelope policy when choosing work. Redis atomically verifies and reserves capacity for the selected envelope, so simultaneous workers cannot over-claim a shared limit.
An idempotency key owns one unfinished run. Duplicate enqueue attempts return the existing envelope while it is queued, delayed, active, or retrying. Cricket releases the key after completion or final failure so a later run can start. Each claimed attempt owns Cricket's lease, evidence, retry, and completion/failure writes; the driver rejects those writes from older attempts. Apps must still make product-side effects idempotent or attempt-aware. Delayed promotion and schedule-slot materialization use the same atomic coordination boundary.
Envelopes, run state, events, current-attempt evidence, and schedule-slot
ownership remain after a job completes or fails. The app chooses how long to
keep that execution history and when cleanup runs. Pass the expired ledger IDs
to jobs.removeFinished(ids); Cricket verifies each job is completed or failed
and removes its Redis records without exposing their key layout. The result
separates removed, already-missing, and skipped IDs. Treat both removed
and missing as safe to delete from cricket_jobs; leave skipped rows for a
later run or operator review.
Run Redis cleanup before deleting ledger rows. If cleanup stops between those
steps, the next run reports the already-clean Redis IDs as missing and can
finish deleting the rows. Keep completed and failed retention windows, batch
size, and cleanup scheduling in app code.
async function removeExpiredJobHistory({ jobs, jobHistory, policy }) {
let expired = await jobHistory.expired(policy);
let result = await jobs.removeFinished(expired.map(row => row.id));
await jobHistory.removeExpired([
...result.removed,
...result.missing
], policy);
return result;
}The app service should recheck the job status and applicable cutoff when it deletes each row. Cricket does not choose retention windows or schedule this work.
worker.cleanup() closes runtime resources and driver-owned Redis connections;
the app still owns any client it supplied. Cleanup does not delete coordination
records.
If the app has a Cricket database, add the job ledger deliberately:
import { createJobLedgerTable } from '@robdel12/cricket/jobs';
export async function up(db) {
await createJobLedgerTable(db);
}
export async function down(db) {
await db.schema.dropTableIfExists('cricket_jobs');
}The ledger is execution history for debugging and operators. It is not product state. Producer enqueue and worker lifecycle inserts are race-safe, so a fast claim cannot collide with a late queued insert or regress an active row back to queued.
Recovery is app-owned. Cricket renews an active claim's heartbeat while its
run function is working and records normal logs, spans, progress, and driver
run state. Your recover function reads that snapshot, including heartbeat age
and ledger-shaped run facts, then returns a plain decision:
return { action: 'continue' };
return { action: 'retry', reason: { code: 'worker_lost' } };
return { action: 'fail', reason: { code: 'outside_business_window' } };Use logs for domain breadcrumbs, trace.span() for timed work, and
progress.update() for human-readable progress. Cricket does not define
"stuck" for you. The job does. Multiple recoverers may evaluate the same
attempt, so keep recovery pure and idempotent. Cricket fences the resulting
transition and reports applied: false when a live attempt still owns its
lease. The optional cricket_jobs database ledger remains separate execution
history; recovery does not require it.
Cricket provides one logger shape, request/job events, sparse timings, trace spans, and lifecycle state.
export let app = defineCricketApp({
domains: './domains',
logger: {
service: 'project-api',
level: process.env.LOG_LEVEL ?? 'info',
format: process.env.NODE_ENV === 'production' ? 'json' : 'pretty'
}
});Use trace.span(name, metadata, fn) around meaningful service calls, external
calls, and job steps. Use pnpm cricket trace when you need to inspect one
request from newline-delimited JSON logs:
docker logs api | pnpm cricket trace req_123Apps can read lifecycle from setup, services, middleware, context, handlers,
job execution, and shutdown hooks. Worker runtimes expose it for worker
entrypoints. Product health checks still decide whether the app is ready for
traffic.
Test through the boundary users consume. Use HTTP tests for endpoints and the worker boundary for jobs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createTestRuntime } from '@robdel12/cricket/test';
import { app } from '../api/index.js';
test('creates a project through the API', async () => {
let { api, cleanup, testState } = await createTestRuntime(app);
try {
let response = await api.post('/api/projects', {
body: {
slug: 'launch-plan',
name: 'Launch Plan'
}
});
assert.equal(response.status, 201);
assert.equal(response.body.project.slug, 'launch-plan');
let request = testState.request(response.requestId);
assert.equal(request.response.status, 201);
} finally {
await cleanup();
}
});Drive scheduled jobs without waiting on wall time:
let worker = await startCricketWorker(app, {
jobs: [generateReport],
queues: {
test: true
},
clock: {
now: () => new Date('2026-06-19T09:16:00.000Z')
}
});
await worker.schedules.tick();
await worker.drain();Direct tick() and drain() tests only need clock.now. If a test exercises
the continuous worker.run() loop with a custom clock, provide
clock.waitUntil too so the test owns deadline advancement without sleeping.
testState exposes events, logs, request traces, job traces, timings, and
runtime reports. It does not reset app state for you.
pnpm cricket init .
pnpm cricket new domain project api/domains --with model,validations,service,routes,test
pnpm cricket inspect api/index.js
pnpm cricket check api/index.js
pnpm cricket docs api/index.js --out openapi.json
pnpm cricket migrate latest api/index.js
pnpm cricket testnew domain requires --with so optional files exist only when the domain
needs them; use --with all when every supported file is intentional. A
selected test starts as a todo until it proves behavior through the HTTP or
worker boundary. The serializer scaffold requires a model in the same selection
or an existing schema.model.js in the domain.
check validates the app architecture and calls out manual migration debt.
inspect prints architecture, loaded domains, model visibility, rules, services, jobs, route
operation IDs, and observability posture. docs writes OpenAPI from the same
app module your server runs. test wraps Node's built-in test runner with
Cricket defaults and optional JSON output.
Run cricket docs api/index.js to generate OpenAPI 3.1 without starting your
server or running setup. It includes every registered endpoint, so choose which
endpoints to include before publishing public docs.
let showReport = defineEndpoint({
method: 'get',
path: '/reports/:id',
params: z.object({ id: z.string() }),
headers: z.object({ 'x-client-version': z.string().optional() }),
auth: [{ bearer: [] }],
rules: [requireUser],
response: {
schema: Report.public,
headers: { 'Cache-Control': { schema: z.string(), example: 'private, no-store' } }
},
handler: async context => withHeaders(ok(await reports.find(context.input.params.id)), {
'Cache-Control': 'private, no-store'
})
});
let app = defineCricketApp({
authMethods: { bearer: { type: 'http', scheme: 'bearer' } },
domains: [{ name: 'report', endpoints: [showReport] }]
});authMethods names the app's authentication methods. Endpoint auth
describes which ones a request needs. Rules and middleware enforce access.
Generated OpenAPI uses the standard securitySchemes and security names.
Methods in one object are required together; separate array entries are
alternatives. auth: [] documents anonymous access. Cricket supports HTTP,
API key, OAuth 2, OpenID Connect, and mutual TLS descriptions.
Use lowercase names in request headers. Cricket validates only those headers
and puts the parsed values in input.headers; missing or invalid required
values return 422. Rules can read raw credentials from request.headers.
Describe authorization through authentication methods, and accept/content-type
through content types.
Response definitions accept schema (or body), serializer, description,
contentType, headers, and example, including under status-specific responses. Each
header needs a schema and can have a description or example. Return its actual
value with withHeaders. HEAD, 204, 205, and 304 responses have no documented body.
let BinaryFile = z.instanceof(Buffer).meta({
jsonSchema: { type: 'string', format: 'binary' }
});
// The handler also sets this Content-Type with withHeaders.
let response = { schema: BinaryFile, contentType: 'application/octet-stream' };Cricket documents Zod input types for requests and output types for responses. A string transformed to a number is a string request or numeric response; response dates become date-time strings.
If Cricket can't describe a transform or custom type, docs generation fails.
Add a known output type with .pipe(...) or describe the sent value with
jsonSchema metadata, as above. This metadata doesn't change validation or
serialization, so check it against a real HTTP response.
Input coercion also needs metadata because JavaScript accepts more than the
resulting type suggests. For a numeric query parameter, use
z.coerce.number().meta({ jsonSchema: { type: 'number' } }).
let body = z.object({ label: z.string() });
let requestBody = {
files: {
type: 'object',
properties: { asset: { type: 'string', format: 'binary' } }
}
};
// Use these with multipart: { maxFiles: 1, maxFileBytes: 1048576 }.requestBody adds a description, example, or contentType to the body
schema. JSON is the default; multipart endpoints use multipart/form-data.
files accepts type, properties, and optional required. File names can't
overlap body fields. Rules check required files, MIME types, and contents;
Cricket enforces the configured size and count limits.
For raw requests, set rawBody and describe the input with requestBody.schema
and a content type such as text/plain. Rules validate the raw data.
requestBody.required only changes the docs; by default, it follows whether
body accepts a missing value.
Model components include public schemas and views containing only public fields. Review endpoint responses and examples too: they can still declare private data. Cricket copies and freezes documentation metadata without freezing your objects.
Versioned docs keep auth, headers, descriptions, and content types. When an older normalizer or serializer replaces a schema, Cricket drops its current body/response example. Put version-specific examples on that version's Zod schema.
Duplicate operations/components, conflicting route parameters, unknown auth
methods, and broken local references fail generation. Use document JSON pointers
for references you write yourself. Cricket moves recursive Zod schemas, including
z.json(), into OpenAPI components and keeps their local references intact.
Generated component names are internal; don't hardcode them. Cricket doesn't
fetch external references.
import {
defineCricketApp,
startCricketApp,
createCricketRuntime,
defineApiVersions,
defineEndpoint,
deprecateEndpoint,
respond,
ok,
created,
redirect,
withHeaders,
withCookies,
withResponseCleanup,
defineModel,
defineRule,
defineSerializer,
defineNormalizer,
field,
createKnexRepository,
z
} from '@robdel12/cricket';
import {
concurrency,
createCricketJobs,
createJobLedgerTable,
cronSchedule,
defineJob,
jobFailure,
redisQueue,
retry,
startCricketWorker,
state
} from '@robdel12/cricket/jobs';Public subpaths are also available:
import { defineCricketApp } from '@robdel12/cricket/app';
import { loadDomains } from '@robdel12/cricket/domain';
import { createKnexRepository } from '@robdel12/cricket/knex';
import { createCricketLogger, normalizeLogger } from '@robdel12/cricket/logger';
import { generateOpenApi } from '@robdel12/cricket/openapi';
import { defineSerializer } from '@robdel12/cricket/serializer';
import { createTestRuntime } from '@robdel12/cricket/test';