Robdel12/cricket

Tiny contracts for sturdy Node APIs.

★ 1Forks 0JavaScriptGitHub ↗Compare
api-frameworkknexnodeopenapizod

README

Cricket

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.

Install

pnpm add @robdel12/cricket

Adopt Cricket

Start 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.json

init creates the folders Cricket expects, adds the Cricket section to AGENTS.md, and installs a small local skill suite under .agents/skills/:

  • cricket teaches the framework shape, domain pattern, and change flow.
  • cricket-jobs teaches jobs, schedules, retries, workers, and the ledger.
  • cricket-observability teaches logging, tracing, lifecycle, and debugging.
  • cricket-testing teaches 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.

Principles

  • 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.

App Shape

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.

Manual architecture is migration debt

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.

Domain Shape

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.

App Entry

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.

Add package domains with plugins

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.

Domain Contracts

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.

Endpoint API versions

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.json

Deprecation 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.

Endpoint responses

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()
);

Multipart requests

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.

Database

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.js

Cricket 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

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.

Observability

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_123

Apps 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.

Testing

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.

CLI

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 test

new 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.

Document your HTTP API

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.

Describe authentication and headers

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.

Describe custom types

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' } }).

Describe uploads and raw bodies

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.

Check the generated docs

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.

See the public API example.

Exports

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';

Contributors

Robdel12

Issues