A production-ready TypeScript boilerplate for building distributed task workers using the MapColonies Jobnik SDK.
- Type-safe task handling with full TypeScript support for job and stage definitions
- Built-in observability with distributed tracing, Prometheus metrics, and structured logging
- Production-ready containerization with multi-stage Docker builds and Helm charts
- Dependency injection using tsyringe for clean, testable architecture
- Health checks and graceful shutdown with @godaddy/terminus
- Comprehensive testing setup with Vitest for unit and integration tests
- Node.js >= 24.0.0
- Connection to a Jobnik Job Manager API instance
npm installConfigure the worker by editing files in the config/ directory:
default.json- Base configurationdevelopment.json- Development overridesproduction.json- Production overridestest.json- Test environment settingslocal.json- Local overrides (not committed to version control)
Set the Jobnik Job Manager API URL via Helm values or environment variables.
npm run start:devDevelopment mode enables offline config mode and source map support for better debugging.
npm run build
npm start# Run all tests with coverage
npm test
# Watch mode for development
npm run test:watchnpm run test:integration needs an S3-compatible server and a Redis server. By default it starts
an S3-compatible and a Redis testcontainer automatically.
The images are pinned to the releases deployed in our environments; bump them in
tests/integration/helpers/s3Container.ts and tests/integration/helpers/redisContainer.ts
when those environments move.
To run against already-running servers instead, set the TEST_* variables:
| Variable | Default | Purpose |
|---|---|---|
TEST_S3_ENDPOINT |
(unset) | Point the suite at an existing S3 server. Unset means start a container. |
TEST_S3_ACCESS_KEY |
minioadmin |
Access key for that server. |
TEST_S3_SECRET_KEY |
minioadmin |
Secret key for that server. |
TEST_REDIS_HOST |
(unset) | Point the suite at an existing Redis. Unset means start a container. |
TEST_REDIS_PORT |
6379 |
Port for that server. |
The suite creates and deletes buckets on whichever S3 server it is given, and calls
FLUSHDBon whichever Redis it is given. Never pointTEST_S3_ENDPOINTorTEST_REDIS_HOSTat a shared or deployed environment, and beware of leaving them exported in a shell profile. Each run prints which mode it selected and against which server.
This boilerplate includes example "logistics" code to demonstrate task handling. Follow these steps to adapt it to your use case:
Delete the example logistics implementation:
rm -rf src/logistics src/seeder.ts tests/logistics.spec.tsRemove the seeder call from src/index.ts:
// REMOVE THESE LINES:
const sdk = container.resolve<LogisticsSDK>(SERVICES.JOBNIK_SDK);
await seedData(sdk.getProducer());Create a new types file (e.g., src/yourDomain/types.ts):
import type { IJobnikSDK } from '@map-colonies/jobnik-sdk';
export interface YourJobTypes {
jobType1: {
data: { /* your job data schema */ };
userMetadata: { /* your job metadata */ };
};
}
export interface YourStageTypes {
stage1: {
data: { /* stage data schema */ };
userMetadata: { /* stage metadata */ };
task: {
data: { /* task data schema */ };
userMetadata: { /* task metadata */ }
};
};
}
export type YourSDK = IJobnikSDK<YourJobTypes, YourStageTypes>;Create your manager (e.g., src/yourDomain/manager.ts):
import { injectable } from 'tsyringe';
import type { Task, TaskHandlerContext } from '@map-colonies/jobnik-sdk';
@injectable()
export class YourManager {
public async handleYourTask(
task: Task<YourStageTypes['stage1']['task']>,
context: TaskHandlerContext<YourJobTypes, YourStageTypes, 'jobType1', 'stage1'>
): Promise<void> {
context.logger.info({ msg: 'Processing task', taskId: task.id });
// Your task processing logic here
await context.updateStageUserMetadata({ /* updated metadata */ });
}
}Modify src/worker.ts to use your new types and handler:
import { YourManager } from './yourDomain/manager';
import type { YourSDK } from './yourDomain/types';
export const workerBuilder: FactoryFunction<IWorker> = (container: DependencyContainer) => {
const sdk = container.resolve<YourSDK>(SERVICES.JOBNIK_SDK);
const manager = container.resolve(YourManager);
const worker = sdk.createWorker<'jobType1', 'stage1'>(
'stage1',
manager.handleYourTask.bind(manager),
config.get('jobnik.worker')
);
return worker;
};Update references to cleaner in:
package.json- name, description, authorhelm/Chart.yaml- name, descriptionhelm/values.yaml- mclabels, configManagement.name, image.repositoryhelm/templates/_helpers.tpl- all template definitionshelm/templates/*.yaml- review all template files for hardcoded references
Edit package.json:
{
"name": "your-worker-name",
"description": "Your worker description",
"author": "Your Team"
}Prometheus metrics are exposed on the /metrics endpoint (default port 8080). Key metrics include:
- Worker task processing duration
- Task success/failure rates
- Active task count
- Custom application metrics
Distributed tracing can be enabled in config/default.json:
{
"telemetry": {
"tracing": {
"isEnabled": true,
"url": "http://your-otlp-collector:4318/v1/trace"
}
}
}Structured JSON logging is provided by @map-colonies/js-logger. Configure log level:
{
"telemetry": {
"logger": {
"level": "info",
"prettyPrint": false
}
}
}Deploy using Helm:
helm install your-worker-name ./helm