{"id":45591753,"url":"https://github.com/openjobspec/ojs-js-sdk","last_synced_at":"2026-03-09T22:04:48.225Z","repository":{"id":338875693,"uuid":"1156976622","full_name":"openjobspec/ojs-js-sdk","owner":"openjobspec","description":"The official Open Job Spec SDK for JavaScript and TypeScript.","archived":false,"fork":false,"pushed_at":"2026-02-28T14:59:54.000Z","size":516,"stargazers_count":0,"open_issues_count":8,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-28T18:55:42.820Z","etag":null,"topics":["background-jobs","javascript","job-queue","nodejs","ojs","openjobspec","sdk","typescript","worker"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/openjobspec.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-13T09:26:21.000Z","updated_at":"2026-02-28T14:54:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/openjobspec/ojs-js-sdk","commit_stats":null,"previous_names":["openjobspec/ojs-js-sdk"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/openjobspec/ojs-js-sdk","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openjobspec%2Fojs-js-sdk","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openjobspec%2Fojs-js-sdk/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openjobspec%2Fojs-js-sdk/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openjobspec%2Fojs-js-sdk/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/openjobspec","download_url":"https://codeload.github.com/openjobspec/ojs-js-sdk/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/openjobspec%2Fojs-js-sdk/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":30314451,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-09T20:05:46.299Z","status":"ssl_error","status_checked_at":"2026-03-09T19:57:04.425Z","response_time":61,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["background-jobs","javascript","job-queue","nodejs","ojs","openjobspec","sdk","typescript","worker"],"created_at":"2026-02-23T12:42:14.359Z","updated_at":"2026-03-09T22:04:48.219Z","avatar_url":"https://github.com/openjobspec.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# @openjobspec/sdk\n\n[![CI](https://github.com/openjobspec/ojs-js-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/openjobspec/ojs-js-sdk/actions/workflows/ci.yml)\n[![npm version](https://img.shields.io/npm/v/@openjobspec/sdk.svg)](https://www.npmjs.com/package/@openjobspec/sdk)\n[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)\n[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)\n\nThe official [Open Job Spec (OJS)](https://openjobspec.org) SDK for JavaScript and TypeScript -- a vendor-neutral, language-agnostic specification for background job processing.\n\n\u003e **🚀 Try it now:** [Open in Playground](https://playground.openjobspec.org?lang=typescript) · [Run on CodeSandbox](https://codesandbox.io/p/sandbox/openjobspec-typescript-quickstart) · [Docker Quickstart](https://github.com/openjobspec/openjobspec/blob/main/docker-compose.quickstart.yml)\n\n## Features\n\n- **Zero dependencies**: Uses built-in `fetch` -- no third-party runtime deps\n- **TypeScript-first**: Full type safety with `.d.ts` declarations and generic-typed enqueue\n- **Dual format**: Ships both ESM and CommonJS builds\n- **Client**: Enqueue jobs, batch operations, workflow management, queue control, cron scheduling\n- **Worker**: Process jobs with configurable concurrency, middleware, and graceful shutdown\n- **Workflows**: Chain (sequential), Group (parallel), Batch (parallel with callbacks)\n- **Middleware**: Composable middleware chain with named operations (add, remove, insertBefore, insertAfter)\n- **Structured errors**: Error class hierarchy with codes, retryable flags, and rate-limit metadata\n- **Events**: CloudEvents-inspired typed event emitter for observability\n- **Serverless**: First-class adapters for Cloudflare Workers and Vercel Edge Functions\n- **OpenTelemetry**: Optional tracing and metrics middleware (peer dependency)\n- **Testing**: Built-in fake mode and assertion helpers for unit tests\n- **Cross-runtime**: Works in Node.js 18+, Deno, and Bun\n\n## Architecture\n\n### Client / Server / Worker Flow\n\n```\n┌──────────────┐         HTTP          ┌──────────────┐         HTTP          ┌──────────────┐\n│              │  POST /ojs/v1/jobs     │              │  POST /workers/fetch  │              │\n│  Application ├───────────────────────\u003e│  OJS Server  │\u003c─────────────────────┤    Worker    │\n│  (Producer)  │   enqueue / batch      │  (Redis /    │   fetch / ack / nack  │  (Consumer)  │\n│              │\u003c───────────────────────┤   Postgres)  ├─────────────────────\u003e│              │\n│  OJSClient   │   201 Created {job}    │              │   {jobs} / heartbeat  │  OJSWorker   │\n└──────────────┘                        └──────────────┘                       └──────────────┘\n        │                                                                             │\n        │  .enqueue()                                                   .register()   │\n        │  .enqueueBatch()                                              .use()        │\n        │  .workflow()                                                  .start()      │\n        │  .cancelJob()                                                 .stop()       │\n        │  .getJob()                                                                  │\n        │  .queues.*                                                                  │\n        │  .cron.*                                                                    │\n        └─────────────────────────────────────────────────────────────────────────────┘\n```\n\n### Worker Lifecycle\n\n```\n             start()\n  ┌──────────┐     ┌─────────┐  Server directive   ┌───────────┐\n  │terminated├────\u003e│ running ├────────────────────\u003e│   quiet   │\n  └──────────┘     └────┬────┘                      └─────┬─────┘\n       ^                │                                  │\n       │                │ stop() / ctx.Done()              │ stop() / server directive\n       │                v                                  v\n       │           ┌─────────────┐                    ┌─────────────┐\n       └───────────┤  terminate  │\u003c───────────────────┤  terminate  │\n                   └─────────────┘  grace period      └─────────────┘\n```\n\n### Middleware Chain (Onion Model)\n\n```\n  Job Fetched ──\u003e [ Middleware 1 before ] ──\u003e [ Middleware 2 before ] ──\u003e [ Handler ]\n                  [ Middleware 1 after  ] \u003c── [ Middleware 2 after  ] \u003c── [ return  ]\n  ACK / NACK \u003c──\n```\n\n## Installation\n\n```bash\nnpm install @openjobspec/sdk\n```\n\n```bash\n# yarn\nyarn add @openjobspec/sdk\n\n# pnpm\npnpm add @openjobspec/sdk\n```\n\n## Quick Start\n\n### Enqueue a Job\n\n```ts\nimport { OJSClient } from '@openjobspec/sdk';\n\nconst client = new OJSClient({ url: 'http://localhost:8080' });\n\n// Simple enqueue\nconst job = await client.enqueue('email.send', { to: 'user@example.com' });\nconsole.log(`Enqueued: ${job.id}`);\n\n// Enqueue with options\nconst delayedJob = await client.enqueue('report.generate', { id: 42 }, {\n  queue: 'reports',\n  delay: '5m',\n  retry: { maxAttempts: 5, backoff: 'exponential' },\n  unique: { key: ['id'], period: 'PT1H' },\n});\n```\n\n### Process Jobs\n\n```ts\nimport { OJSWorker } from '@openjobspec/sdk';\n\nconst worker = new OJSWorker({\n  url: 'http://localhost:8080',\n  queues: ['default', 'email'],\n  concurrency: 10,\n});\n\nworker.register('email.send', async (ctx) =\u003e {\n  const { to, subject } = ctx.job.args[0] as { to: string; subject: string };\n  await sendEmail(to, subject);\n  return { sent: true };\n});\n\n// Add middleware\nworker.use(async (ctx, next) =\u003e {\n  console.log(`Processing ${ctx.job.type}`);\n  const start = Date.now();\n  await next();\n  console.log(`Done in ${Date.now() - start}ms`);\n});\n\nawait worker.start();\n\n// Graceful shutdown\nprocess.on('SIGTERM', () =\u003e worker.stop());\n```\n\n### Typed Enqueue (Generics)\n\nUse the generic parameter on `enqueue\u003cT\u003e()` for compile-time argument safety:\n\n```ts\ninterface EmailPayload {\n  to: string;\n  subject: string;\n  body: string;\n}\n\nconst job = await client.enqueue\u003cEmailPayload\u003e('email.send', {\n  to: 'user@example.com',\n  subject: 'Welcome',\n  body: 'Hello!',\n});\n```\n\n## Client API Reference\n\n### OJSClient\n\n| Method | Signature | Returns | Description |\n|--------|-----------|---------|-------------|\n| `enqueue` | `enqueue\u003cT\u003e(type, args, options?)` | `Promise\u003cJob\u003e` | Enqueue a single job |\n| `enqueueBatch` | `enqueueBatch(specs)` | `Promise\u003cJob[]\u003e` | Enqueue multiple jobs atomically |\n| `getJob` | `getJob(jobId)` | `Promise\u003cJob\u003e` | Get job details by ID |\n| `cancelJob` | `cancelJob(jobId)` | `Promise\u003cJob\u003e` | Cancel a job by ID |\n| `workflow` | `workflow(definition)` | `Promise\u003cWorkflowStatus\u003e` | Create and start a workflow |\n| `getWorkflow` | `getWorkflow(workflowId)` | `Promise\u003cWorkflowStatus\u003e` | Get workflow status |\n| `cancelWorkflow` | `cancelWorkflow(workflowId)` | `Promise\u003cvoid\u003e` | Cancel a workflow |\n| `health` | `health()` | `Promise\u003c{status, version, backend?}\u003e` | Check server health |\n| `manifest` | `manifest()` | `Promise\u003cRecord\u003cstring, unknown\u003e\u003e` | Fetch conformance manifest |\n| `useEnqueue` | `useEnqueue(name, fn)` | `this` | Add enqueue middleware |\n\n### Sub-Modules on OJSClient\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `client.queues` | `QueueOperations` | Queue management (list, stats, pause, resume, dead letter) |\n| `client.cron` | `CronOperations` | Cron job management (list, register, unregister) |\n| `client.schemas` | `SchemaOperations` | Schema management (list, register, delete) |\n| `client.events` | `OJSEventEmitter` | Client-side event emitter |\n| `client.middleware` | `MiddlewareChain` | Fine-grained enqueue middleware chain access |\n\n### Queue Operations\n\n```ts\n// List all queues\nconst queues = await client.queues.list();\n\n// Get queue statistics\nconst stats = await client.queues.stats('email');\n\n// Pause / resume a queue\nawait client.queues.pause('email');\nawait client.queues.resume('email');\n\n// Dead letter management\nconst deadJobs = await client.queues.listDeadLetter();\nawait client.queues.retryDeadLetter(deadJobs[0].id);\nawait client.queues.discardDeadLetter(deadJobs[1].id);\n```\n\n### Cron Operations\n\n```ts\n// Register a cron job\nawait client.cron.register({\n  name: 'daily-report',\n  cron: '0 9 * * *',\n  timezone: 'America/New_York',\n  type: 'report.generate',\n  args: { format: 'pdf' },\n  options: { queue: 'reports' },\n});\n\n// List cron jobs (with pagination)\nconst { cron_jobs, pagination } = await client.cron.list({ page: 1, per_page: 20 });\n\n// Unregister a cron job\nawait client.cron.unregister('daily-report');\n```\n\n### Batch Enqueue\n\n```ts\nconst jobs = await client.enqueueBatch([\n  { type: 'email.send', args: { to: 'a@example.com' } },\n  { type: 'email.send', args: { to: 'b@example.com' } },\n  { type: 'sms.send', args: { phone: '+15551234567' }, options: { queue: 'sms' } },\n]);\n```\n\n## Worker API Reference\n\n### OJSWorker\n\n| Method / Property | Signature | Returns | Description |\n|-------------------|-----------|---------|-------------|\n| `register` | `register(type, handler)` | `this` | Register a handler for a job type |\n| `use` | `use(fn)` / `use(name, fn)` | `this` | Add execution middleware |\n| `start` | `start()` | `Promise\u003cvoid\u003e` | Start polling for jobs |\n| `stop` | `stop()` | `Promise\u003cvoid\u003e` | Graceful shutdown |\n| `currentState` | getter | `WorkerState` | Current lifecycle state (`running` / `quiet` / `terminate` / `terminated`) |\n| `activeJobCount` | getter | `number` | Number of in-flight jobs |\n| `workerId` | readonly | `string` | Unique worker instance ID |\n| `events` | readonly | `OJSEventEmitter` | Worker-side event emitter |\n| `middleware` | getter | `MiddlewareChain` | Fine-grained execution middleware chain access |\n\n### JobContext\n\nThe context object passed to every handler and middleware:\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `job` | `Job` | The full job envelope |\n| `attempt` | `number` | Current attempt number (1-indexed) |\n| `queue` | `string` | The queue the job was fetched from |\n| `workerId` | `string` | The worker ID that claimed this job |\n| `workflowId` | `string?` | Workflow ID if part of a workflow |\n| `parentResults` | `Record\u003cstring, JsonValue\u003e?` | Upstream workflow step results |\n| `metadata` | `Map\u003cstring, unknown\u003e` | Mutable metadata store scoped to this execution |\n| `signal` | `AbortSignal` | Signal for cooperative cancellation / timeout |\n\n## Workflows\n\nThree workflow primitives are available, matching the OJS Workflow Specification:\n\n### Chain (Sequential Execution)\n\nJobs execute one after another. The result of step N feeds step N+1.\n\n```ts\nimport { OJSClient, chain } from '@openjobspec/sdk';\n\nconst client = new OJSClient({ url: 'http://localhost:8080' });\n\nconst workflow = await client.workflow(\n  chain(\n    { type: 'data.fetch', args: { url: 'https://api.example.com/data' } },\n    { type: 'data.transform', args: { format: 'csv' } },\n    { type: 'data.load', args: { dest: 'warehouse' } },\n  )\n);\n\nconsole.log(`Workflow ${workflow.id} state: ${workflow.state}`);\n```\n\n### Group (Parallel Execution)\n\nAll jobs execute concurrently and independently.\n\n```ts\nimport { group } from '@openjobspec/sdk';\n\nconst workflow = await client.workflow(\n  group(\n    { type: 'export.csv', args: { reportId: 'rpt_456' } },\n    { type: 'export.pdf', args: { reportId: 'rpt_456' } },\n    { type: 'export.xlsx', args: { reportId: 'rpt_456' } },\n  )\n);\n```\n\n### Batch (Parallel with Callbacks)\n\nLike a group, but fires callback jobs based on the collective outcome.\n\n```ts\nimport { batch } from '@openjobspec/sdk';\n\nconst workflow = await client.workflow(\n  batch(\n    [\n      { type: 'email.send', args: { to: 'user1@example.com' } },\n      { type: 'email.send', args: { to: 'user2@example.com' } },\n      { type: 'email.send', args: { to: 'user3@example.com' } },\n    ],\n    {\n      on_complete: { type: 'batch.report', args: { notify: 'admin' } },\n      on_success: { type: 'batch.celebrate', args: {} },\n      on_failure: { type: 'batch.alert', args: { channel: '#ops' } },\n    },\n  )\n);\n```\n\n### Nested Workflows\n\nChain, group, and batch can be nested:\n\n```ts\nconst workflow = await client.workflow(\n  chain(\n    { type: 'data.fetch', args: { source: 'api' } },\n    group(\n      { type: 'transform.csv', args: {} },\n      { type: 'transform.json', args: {} },\n    ),\n    { type: 'data.merge', args: {} },\n  )\n);\n```\n\n### Workflow Management\n\n```ts\n// Check workflow status\nconst status = await client.getWorkflow(workflow.id);\nconsole.log(`${status.metadata.completed_count}/${status.metadata.job_count} jobs done`);\n\n// Cancel a running workflow\nawait client.cancelWorkflow(workflow.id);\n```\n\n## Middleware\n\nThe SDK uses an onion-model middleware chain for both worker execution and client enqueue operations. Each middleware wraps the next using the `(ctx, next) =\u003e ...` pattern.\n\n### Writing Custom Middleware\n\n```ts\n// Execution middleware (worker-side)\nworker.use(async (ctx, next) =\u003e {\n  const start = Date.now();\n  console.log(`[${ctx.job.type}] Starting attempt ${ctx.attempt}`);\n\n  try {\n    await next();\n    console.log(`[${ctx.job.type}] Completed in ${Date.now() - start}ms`);\n  } catch (error) {\n    console.error(`[${ctx.job.type}] Failed after ${Date.now() - start}ms`, error);\n    throw error;  // Re-throw to trigger NACK\n  }\n});\n\n// Enqueue middleware (client-side)\nclient.useEnqueue('add-trace-id', async (job, next) =\u003e {\n  job.meta = { ...job.meta, traceId: crypto.randomUUID() };\n  return next(job);\n});\n```\n\n### Named Middleware Operations\n\nAll middleware entries are named, enabling fine-grained chain manipulation:\n\n```ts\n// Add named middleware\nworker.use('logging', async (ctx, next) =\u003e {\n  console.log(`Processing ${ctx.job.type}`);\n  await next();\n});\n\nworker.use('metrics', async (ctx, next) =\u003e {\n  const start = performance.now();\n  await next();\n  recordDuration(ctx.job.type, performance.now() - start);\n});\n\n// Insert relative to existing middleware\nworker.middleware.insertBefore('metrics', 'auth', async (ctx, next) =\u003e {\n  verifyJobOrigin(ctx.job);\n  await next();\n});\n\nworker.middleware.insertAfter('logging', 'tracing', async (ctx, next) =\u003e {\n  const span = tracer.startSpan(`process ${ctx.job.type}`);\n  try {\n    await next();\n  } finally {\n    span.end();\n  }\n});\n\n// Remove middleware by name\nworker.middleware.remove('logging');\n\n// Prepend to the beginning of the chain\nworker.middleware.prepend('error-boundary', async (ctx, next) =\u003e {\n  try { await next(); } catch (e) { reportToSentry(e); throw e; }\n});\n\n// Check if middleware exists\nif (worker.middleware.has('metrics')) { /* ... */ }\n```\n\n### MiddlewareChain API\n\n| Method | Signature | Description |\n|--------|-----------|-------------|\n| `add` | `add(name, fn)` | Append middleware to the end |\n| `prepend` | `prepend(name, fn)` | Insert at the beginning |\n| `insertBefore` | `insertBefore(existingName, name, fn)` | Insert before a named middleware |\n| `insertAfter` | `insertAfter(existingName, name, fn)` | Insert after a named middleware |\n| `remove` | `remove(name)` | Remove middleware by name |\n| `has` | `has(name)` | Check if a named middleware exists |\n| `entries` | `entries()` | Get the ordered middleware list |\n| `clear` | `clear()` | Remove all middleware |\n| `length` | getter | Number of middleware entries |\n\n## Error Handling\n\nThe SDK provides a structured error hierarchy. All errors extend `OJSError` and include a machine-readable `code`, a `retryable` flag, and optional `details`.\n\n### Error Class Hierarchy\n\n| Class | Code | HTTP Status | Retryable | Description |\n|-------|------|-------------|-----------|-------------|\n| `OJSError` | (varies) | -- | -- | Base class for all OJS errors |\n| `OJSValidationError` | `invalid_request` | 400 | No | Request validation failed |\n| `OJSNotFoundError` | `not_found` | 404 | No | Job or resource not found |\n| `OJSDuplicateError` | `duplicate` | 409 | No | Unique constraint conflict |\n| `OJSConflictError` | `conflict` | 409 | No | State conflict |\n| `OJSRateLimitError` | `rate_limited` | 429 | Yes | Rate limit exceeded |\n| `OJSServerError` | `server_error` | 5xx | Yes | Internal server error |\n| `OJSConnectionError` | `connection_error` | -- | Yes | Network / connection failure |\n| `OJSTimeoutError` | `timeout` | -- | Yes | Job handler exceeded timeout |\n\n### Error Handling Example\n\n```ts\nimport {\n  OJSError,\n  OJSValidationError,\n  OJSDuplicateError,\n  OJSNotFoundError,\n  OJSRateLimitError,\n  OJSConnectionError,\n} from '@openjobspec/sdk';\n\ntry {\n  const job = await client.enqueue('email.send', { to: 'user@example.com' });\n} catch (error) {\n  if (error instanceof OJSDuplicateError) {\n    console.log(`Job already exists: ${error.existingJobId}`);\n  } else if (error instanceof OJSNotFoundError) {\n    console.log('Resource not found');\n  } else if (error instanceof OJSRateLimitError) {\n    console.log(`Rate limited. Retry after ${error.retryAfter}s`);\n    console.log(`Remaining: ${error.rateLimit?.remaining}/${error.rateLimit?.limit}`);\n  } else if (error instanceof OJSValidationError) {\n    console.log(`Validation failed: ${error.message}`);\n    console.log('Details:', error.details);\n  } else if (error instanceof OJSConnectionError) {\n    console.log('Server unreachable, will retry...');\n  } else if (error instanceof OJSError) {\n    console.log(`OJS error [${error.code}]: ${error.message}`);\n    console.log(`Retryable: ${error.retryable}`);\n    console.log(`Request ID: ${error.requestId}`);\n  }\n}\n```\n\n### Non-Retryable Handler Errors\n\nBy default, handler errors are retryable. When a handler encounters a permanent failure, it should communicate this through the error's structure so the server can discard the job rather than retrying it:\n\n```ts\nworker.register('email.send', async (ctx) =\u003e {\n  const { to } = ctx.job.args[0] as { to: string };\n\n  if (!isValidEmail(to)) {\n    // Throw a structured error -- the worker will NACK with retryable: false\n    const err = new Error(`Invalid email address: ${to}`);\n    (err as any).retryable = false;\n    throw err;\n  }\n\n  await sendEmail(to);\n});\n```\n\n## Events\n\nBoth `OJSClient` and `OJSWorker` expose a typed `OJSEventEmitter` following the CloudEvents-inspired OJS event vocabulary.\n\n### Subscribing to Events\n\n```ts\n// Type-safe event subscription\nconst unsubscribe = worker.events.on('job.completed', (event) =\u003e {\n  console.log(`Job ${event.subject} completed in ${event.data.duration_ms}ms`);\n  console.log(`Queue: ${event.data.queue}, Attempt: ${event.data.attempt}`);\n});\n\nworker.events.on('job.failed', (event) =\u003e {\n  console.error(`Job ${event.subject} failed: ${event.data.error.message}`);\n});\n\nworker.events.on('worker.started', (event) =\u003e {\n  console.log(`Worker ${event.data.worker_id} started on queues: ${event.data.queues}`);\n});\n\nworker.events.on('worker.stopped', (event) =\u003e {\n  console.log(`Worker stopped. Processed ${event.data.jobs_completed} jobs in ${event.data.uptime_ms}ms`);\n});\n\n// Subscribe to all events\nworker.events.onAny((event) =\u003e {\n  metricsCollector.record(event.type, event.data);\n});\n\n// Unsubscribe when done\nunsubscribe();\n\n// Remove all listeners\nworker.events.removeAllListeners();\n```\n\n### Event Types\n\n| Event Type | Data Fields | Description |\n|------------|-------------|-------------|\n| `job.enqueued` | `job_type`, `queue`, `priority?`, `scheduled_at?` | A job was enqueued |\n| `job.started` | `job_type`, `queue`, `worker_id`, `attempt` | A job started processing |\n| `job.completed` | `job_type`, `queue`, `duration_ms`, `attempt`, `result?` | A job completed successfully |\n| `job.failed` | `job_type`, `queue`, `attempt`, `error` | A job handler failed |\n| `job.retrying` | `job_type`, `queue`, `attempt`, `max_attempts`, `next_retry_at` | A job is scheduled for retry |\n| `job.cancelled` | -- | A job was cancelled |\n| `job.discarded` | -- | A job was discarded (exhausted retries) |\n| `worker.started` | `worker_id`, `queues`, `concurrency` | Worker started polling |\n| `worker.stopped` | `worker_id`, `reason`, `jobs_completed`, `uptime_ms` | Worker stopped |\n\n## Testing\n\nThe SDK includes a built-in testing module that intercepts enqueue calls and stores jobs in memory, so you can write unit tests without a running OJS server.\n\n### Fake Mode\n\n```ts\nimport { OJSClient, testing } from '@openjobspec/sdk';\n\n// Activate before each test\nbeforeEach(() =\u003e testing.fake());\nafterEach(() =\u003e testing.restore());\n\ntest('signup enqueues a welcome email', async () =\u003e {\n  const client = new OJSClient({ url: 'http://localhost:8080' });\n\n  // This enqueue goes to the in-memory store, not the network\n  await client.enqueue('email.send', { to: 'newuser@example.com', template: 'welcome' });\n\n  // Assert the job was enqueued\n  testing.assertEnqueued('email.send', {\n    args: [{ to: 'newuser@example.com', template: 'welcome' }],\n  });\n\n  // Assert specific count\n  testing.assertEnqueued('email.send', { count: 1 });\n\n  // Assert no unexpected jobs\n  testing.refuteEnqueued('sms.send');\n});\n```\n\n### Inline Mode\n\nInline mode executes handlers synchronously at enqueue time, useful for integration-style tests:\n\n```ts\nbeforeEach(() =\u003e {\n  testing.inline();\n  testing.registerHandler('email.send', async (job) =\u003e {\n    // Handler runs immediately when enqueued\n    console.log(`Would send email to ${job.args[0]}`);\n  });\n});\n\ntest('signup flow completes end-to-end', async () =\u003e {\n  const client = new OJSClient({ url: 'http://localhost:8080' });\n  await client.enqueue('email.send', { to: 'user@example.com' });\n\n  testing.assertPerformed('email.send');\n  testing.assertCompleted('email.send');\n});\n```\n\n### Drain (Process Pending Jobs)\n\nIn fake mode, use `drain()` to process all pending jobs with registered handlers:\n\n```ts\ntesting.fake();\ntesting.registerHandler('email.send', async (job) =\u003e {\n  // process job\n});\n\nconst client = new OJSClient({ url: 'http://localhost:8080' });\nawait client.enqueue('email.send', { to: 'user@example.com' });\n\n// Process all pending jobs\nawait testing.drain();\ntesting.assertCompleted('email.send');\n\n// Or limit how many jobs to drain\nawait testing.drain({ maxJobs: 5 });\n```\n\n### Testing API\n\n| Function | Description |\n|----------|-------------|\n| `testing.fake()` | Activate fake mode (jobs stored in memory) |\n| `testing.inline()` | Activate inline mode (handlers run at enqueue time) |\n| `testing.restore()` | Restore real mode and clear all state |\n| `testing.registerHandler(type, fn)` | Register handler for inline mode |\n| `testing.assertEnqueued(type, opts?)` | Assert job(s) were enqueued |\n| `testing.refuteEnqueued(type, opts?)` | Assert no jobs of type were enqueued |\n| `testing.assertPerformed(type, opts?)` | Assert job was performed (inline mode) |\n| `testing.assertCompleted(type)` | Assert job completed successfully |\n| `testing.assertFailed(type)` | Assert job failed |\n| `testing.allEnqueued(filter?)` | Get all enqueued jobs |\n| `testing.drain(opts?)` | Process all pending jobs using registered handlers |\n| `testing.clearAll()` | Clear all enqueued and performed jobs |\n\n## OpenTelemetry\n\nThe SDK provides optional OpenTelemetry middleware for distributed tracing and metrics. Install `@opentelemetry/api` as a peer dependency:\n\n```bash\nnpm install @opentelemetry/api\n```\n\n```ts\nimport { OJSWorker, openTelemetryMiddleware } from '@openjobspec/sdk';\nimport { trace, metrics } from '@opentelemetry/api';\n\nconst worker = new OJSWorker({\n  url: 'http://localhost:8080',\n  queues: ['default'],\n});\n\nworker.use('otel', openTelemetryMiddleware({\n  tracerProvider: trace.getTracerProvider(),\n  meterProvider: metrics.getMeterProvider(),\n}));\n\nawait worker.start();\n```\n\n### What Gets Instrumented\n\n**Traces** -- One `CONSUMER` span per job with attributes:\n\n| Attribute | Value |\n|-----------|-------|\n| `messaging.system` | `ojs` |\n| `messaging.operation` | `process` |\n| `ojs.job.type` | Job type (e.g., `email.send`) |\n| `ojs.job.id` | UUIDv7 job ID |\n| `ojs.job.queue` | Queue name |\n| `ojs.job.attempt` | Attempt number |\n\n**Metrics** -- Three instruments:\n\n| Metric | Type | Description |\n|--------|------|-------------|\n| `ojs.job.completed` | Counter | Jobs completed successfully |\n| `ojs.job.failed` | Counter | Jobs that failed |\n| `ojs.job.duration` | Histogram | Execution duration in seconds |\n\nAll metrics are tagged with `ojs.job.type` and `ojs.job.queue`.\n\n## Serverless\n\nThe SDK ships with first-class adapters for serverless platforms that process jobs via HTTP webhooks from the OJS server.\n\n### Cloudflare Workers\n\n```ts\nimport { createWorkerHandler } from '@openjobspec/sdk/serverless/cloudflare';\n\nconst handler = createWorkerHandler({\n  url: 'https://ojs.example.com',\n  apiKey: 'your-api-key',\n});\n\nhandler.register('email.send', async (ctx) =\u003e {\n  const { to, subject } = ctx.job.args[0] as { to: string; subject: string };\n  await sendEmail(to, subject);\n});\n\nexport default {\n  async fetch(request: Request, env: Env): Promise\u003cResponse\u003e {\n    return handler.handleRequest(request);\n  },\n};\n```\n\n### Vercel Edge Functions\n\n```ts\n// app/api/ojs/route.ts (Next.js App Router)\nimport { createEdgeHandler } from '@openjobspec/sdk/serverless/vercel';\n\nconst handler = createEdgeHandler({\n  url: process.env.OJS_URL!,\n  apiKey: process.env.OJS_API_KEY,\n});\n\nhandler.register('notification.send', async (ctx) =\u003e {\n  const payload = ctx.job.args[0] as { userId: string; message: string };\n  await sendNotification(payload.userId, payload.message);\n});\n\nexport const POST = handler.handleRequest;\nexport const runtime = 'edge';\n```\n\nBoth adapters automatically ACK on success and NACK on failure by calling back to the OJS server.\n\n## Configuration Reference\n\n### OJSClientConfig\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `url` | `string` | -- (required) | Base URL of the OJS server |\n| `auth` | `string?` | -- | Authorization header value (e.g., `'Bearer \u003ctoken\u003e'`) |\n| `headers` | `Record\u003cstring, string\u003e?` | -- | Custom headers for every request |\n| `timeout` | `number?` | -- | Default request timeout in milliseconds |\n| `transport` | `Transport?` | `HttpTransport` | Custom transport implementation (for testing) |\n\n### EnqueueOptions\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `queue` | `string?` | `'default'` | Target queue |\n| `priority` | `number?` | -- | Job priority |\n| `timeout` | `number?` | -- | Execution timeout in milliseconds |\n| `delay` | `string?` | -- | Delay before execution (`'5m'`, `'30s'`, `'1h'`, or ISO 8601) |\n| `expiresAt` | `string?` | -- | Expiration timestamp (RFC 3339) |\n| `retry` | `RetryOptions?` | -- | Custom retry policy |\n| `unique` | `UniqueOptions?` | -- | Deduplication policy |\n| `tags` | `string[]?` | -- | Tags for filtering |\n| `meta` | `Record\u003cstring, JsonValue\u003e?` | -- | Metadata key-value pairs |\n| `schema` | `string?` | -- | JSON Schema name for args validation |\n| `visibilityTimeout` | `number?` | -- | Visibility timeout in milliseconds |\n\n### RetryOptions\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `maxAttempts` | `number?` | -- | Maximum number of retry attempts |\n| `backoff` | `'none' \\| 'linear' \\| 'exponential' \\| 'polynomial'?` | -- | Backoff strategy |\n| `backoffCoefficient` | `number?` | -- | Multiplier for backoff intervals |\n| `initialInterval` | `string?` | -- | Initial retry interval (ISO 8601 duration) |\n| `maxInterval` | `string?` | -- | Maximum retry interval (ISO 8601 duration) |\n| `jitter` | `boolean?` | -- | Add random jitter to backoff |\n| `nonRetryableErrors` | `string[]?` | -- | Error codes that should not be retried |\n| `onExhaustion` | `'discard' \\| 'dead_letter'?` | -- | Action when retries are exhausted |\n\n### UniqueOptions\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `key` | `string[]?` | -- | Fields from args to use as uniqueness key |\n| `period` | `string?` | -- | Uniqueness window (ISO 8601 duration) |\n| `onConflict` | `'reject' \\| 'replace' \\| 'ignore'?` | -- | Conflict resolution strategy |\n| `states` | `JobState[]?` | -- | Job states to check for duplicates |\n\n### OJSWorkerConfig\n\n| Option | Type | Default | Description |\n|--------|------|---------|-------------|\n| `url` | `string` | -- (required) | Base URL of the OJS server |\n| `queues` | `string[]?` | `['default']` | Queues to poll (priority order) |\n| `concurrency` | `number?` | `10` | Maximum parallel jobs |\n| `pollInterval` | `number?` | `1000` | Poll interval in ms when idle |\n| `heartbeatInterval` | `number?` | `5000` | Heartbeat interval in ms |\n| `shutdownTimeout` | `number?` | `25000` | Grace period for shutdown in ms |\n| `visibilityTimeout` | `number?` | `30000` | Visibility timeout per fetch in ms |\n| `auth` | `string?` | -- | Authorization header value |\n| `headers` | `Record\u003cstring, string\u003e?` | -- | Custom headers |\n| `transport` | `Transport?` | `HttpTransport` | Custom transport (for testing) |\n| `labels` | `string[]?` | `[]` | Worker labels for filtering and grouping |\n\n## OJS Spec Conformance\n\nThis SDK implements the [Open Job Spec v1.0](https://openjobspec.org) specification:\n\n- **Layer 1 (Core)**: Job envelope, 8-state lifecycle, retry policies, unique jobs, workflows, middleware chains\n- **Layer 2 (Wire Format)**: JSON encoding with `application/openjobspec+json` content type\n- **Layer 3 (HTTP Binding)**: Full HTTP REST protocol binding (PUSH, FETCH, ACK, NACK, BEAT, CANCEL, INFO)\n- **Worker Protocol**: Four-state lifecycle (`running` / `quiet` / `terminate` / `terminated`), heartbeat, server-directed state changes, graceful shutdown\n\n## Contributing\n\n```bash\n# Install dependencies\nnpm install\n\n# Build\nnpm run build\n\n# Run tests\nnpm test\n\n# Watch mode\nnpm run test:watch\n\n# Type check\nnpm run lint\n\n# Generate docs\nnpm run docs\n```\n\nPlease read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the contribution process and coding conventions.\n\n## License\n\nApache-2.0 -- see [LICENSE](LICENSE).\n\n\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopenjobspec%2Fojs-js-sdk","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fopenjobspec%2Fojs-js-sdk","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fopenjobspec%2Fojs-js-sdk/lists"}