scheduled-job example #

View this page as Markdown

An HTTP service with an hourly scheduled job and tests using its test double.

These files come from the same source as the scaffold command. They are the starter files before the command adds library packages, application settings, and installed dependencies.

With a shell #

Run this in a new project's root:

turnzero scaffold --template scheduled-job

Follow the command's next steps. It writes the application in app/, takes the required library packages, installs dependencies, and runs the tests. The turnzero command explains setup and options.

From a chat #

Follow Deploying from a chat. Read the files below, supply the library versions in the manifest, and send the application files through deploy_files.

The scaffold can bind its manifest to an application with --application <application id>. From a chat, name the application explicitly in submit_manifest and deploy_files. An uploaded manifest may omit application; if present, it must match that application.

Keep the application paths shown in the table. The download URLs end in .txt so a browser reads the code instead of running it; that suffix is not part of the application filename. Each file is also reproduced below for tools reading this page through MCP.

The template manifest starts with an empty packages list. For each @turnzero/ dependency, read its library entry and add its current name and version to that list. Keep the file:lib/… dependencies, and omit those library folders from deploy_files so Cloud fills them. Do not send placeholder versions or hashes.

The source cron uses minute 0. The scaffold command replaces that minute with the current UTC minute. When using these files directly, choose a minute from 0 to 59 for the hourly schedule before submitting the manifest.

Files #

Paths are relative to the application root—the folder containing package.json. deploy_files uses these paths without an app/ prefix.

Application path Read source
.gitignore Text
AGENTS.md Text
manifest.json Text
package.json Text
src/jobs.mjs Text
src/main.mjs Text
src/server.mjs Text
test/job.test.mjs Text

File contents #

.gitignore #

node_modules/
.env
.env.*

AGENTS.md #

# This service runs on Turn Zero Cloud

The service in this folder is hosted on Turn Zero Cloud. It started from the platform's `scheduled-job` template: the smallest Node.js service that deploys, with one handler the platform runs on a schedule. This file describes the template the service started from: keep it current as the service changes.

It started with one schedule, `heartbeat`, which runs the handler in `src/jobs.mjs` once an hour through the Schedule package, and the handler prints one line.

## Where the rules are

Turn Zero Cloud serves its rules to a coding tool through its MCP server, `turnzero-cloud`, and they change with the platform, so none is repeated here.

- Where the server is not connected, connect it first. The platform's page "Connect your AI tool" says how.
- Before you change or deploy this service, read the server's `deploy` skill, and before you change its schedule, the server's `schedule` skill.

## What you run here

- The tests: `npm test`, in this folder.
- A local run and a deploy: the `deploy` skill gives each step and the line to run, in this order: `submit_manifest`, the local run, `deploy`.
- A run on the platform: `run_schedule` with `wait_seconds` fires the schedule now and answers how the run ended. A settled answer carries the handler's lines in `console`. Where they were cut, may still be arriving, or could not be read, its `more` names the `read_logs` call under the source `container` that reads them on. An answer not yet settled names `read_schedules`, which lists every run.

manifest.json #

{
  "manifest_version": 1,
  "services": [
    {
      "kind": "schedule",
      "schedules": [
        {
          "name": "heartbeat",
          "cron": "0 * * * *",
          "path": "/jobs/heartbeat"
        }
      ]
    }
  ],
  "health": "/health",
  "region": "usa",
  "egress": [],
  "audience": { "kind": "public" },
  "packages": []
}

package.json #

{
  "name": "scheduled-job",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/main.mjs",
    "test": "node --test"
  },
  "dependencies": {
    "@turnzero/schedule": "file:lib/schedule"
  }
}

src/jobs.mjs #

// src/jobs.mjs, the service's scheduled handlers, one for each schedule
// manifest.json declares
//
// manifest.json declares one schedule, heartbeat, due once an hour. The
// minute in its cron is the minute of the hour, in UTC, at which the
// scaffold wrote this service, so that the services written from this
// template are not all due at the same moment. Any minute serves: the cron
// is yours to change, to any timetable your plan's shortest interval admits.
//
// A run of the same due time can reach the handler twice. This handler only
// prints a line, which is safe to repeat, so it keeps no record of the runs
// it has handled. A handler whose effect must not repeat records the run's
// key first, in a row of a database or a file in a storage area, and skips
// the effect where the key is recorded already.
//
// On the platform only the platform's own runs reach a handler's path with
// the four headers the router admits a run by. On your machine any caller
// that reaches the service's port can send them, which matters once a
// handler does more than print.
export const jobs = {
  // Prints one line, naming the schedule and the run's key, and answers
  // nothing, so the router answers 200 with a JSON body naming the run.
  heartbeat: (run) => {
    console.log(`schedule ${run.schedule} ran, key ${run.key}`);
  },
};

src/main.mjs #

// src/main.mjs, the process entry the start script runs
import { createAppServer } from './server.mjs';

const port = Number(process.env.PORT ?? 8080);
createAppServer().listen(port, () => console.log(`listening on ${port}`));

src/server.mjs #

// src/server.mjs
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { createScheduleRouter, scheduleDeclarations } from '@turnzero/schedule';
import { jobs } from './jobs.mjs';

// The router for the schedules manifest.json declares, each answered by its
// handler in jobs.mjs. It refuses a handler the manifest does not declare
// and a declared schedule with no handler, so a mismatch stops the service
// as it starts, before any run.
export function createJobRouter() {
  const manifest = JSON.parse(readFileSync(new URL('../manifest.json', import.meta.url), 'utf8'));
  return createScheduleRouter({ schedules: scheduleDeclarations(manifest), handlers: jobs });
}

export function createAppServer() {
  const router = createJobRouter();
  return createServer(async (request, response) => {
    // The deployed runtime's abort signal: end the response when it fires.
    request.signal?.addEventListener('abort', () => response.destroy(), { once: true });
    // The schedule's path first: the router answers it, and leaves every other path to the routes below.
    if (await router.handle(request, response)) return;
    const send = (status, type, body) => {
      if (response.destroyed) return;
      response.writeHead(status, { 'content-type': type });
      response.end(body);
    };
    // A target that does not parse as a URL names no route, so it is answered 404 below.
    let pathname = null;
    try {
      pathname = new URL(request.url, 'http://localhost').pathname;
    } catch {}
    if (request.method === 'GET' && pathname === '/health') return send(200, 'application/json', JSON.stringify({ ok: true }));
    if (request.method === 'GET' && pathname === '/api/hello') return send(200, 'text/plain; charset=utf-8', 'hello world');
    send(404, 'application/json', JSON.stringify({ error: 'not_found' }));
  });
}

test/job.test.mjs #

// test/job.test.mjs
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { after, before, test } from 'node:test';
import { createScheduleDouble } from '@turnzero/schedule/testing';
import { createAppServer, createJobRouter } from '../src/server.mjs';

// The schedule manifest.json declares, with its path.
const manifest = JSON.parse(readFileSync(new URL('../manifest.json', import.meta.url), 'utf8'));
const [heartbeat] = manifest.services.find((service) => service.kind === 'schedule').schedules;

// The server on a port of this machine, for the file.
let server;
let base;

before(async () => {
  server = createAppServer();
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  base = `http://127.0.0.1:${server.address().port}`;
});

after(async () => {
  await new Promise((resolve) => server.close(resolve));
});

test('GET /health returns 200', async () => {
  const response = await fetch(`${base}/health`);
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), { ok: true });
});

test('GET /api/hello returns the text hello world', async () => {
  const response = await fetch(`${base}/api/hello`);
  assert.equal(response.status, 200);
  assert.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8');
  assert.equal(await response.text(), 'hello world');
});

test('the heartbeat schedule, fired through the test double, is answered 200 and prints its line once', async (t) => {
  const printed = t.mock.method(console, 'log', () => {});
  const due = '2026-01-01T09:00:00.000Z';
  const double = createScheduleDouble(createJobRouter(), { now: () => new Date(due) });
  const fired = await double.fire('heartbeat');
  assert.equal(fired.outcome, 'answered');
  assert.equal(fired.status, 200);
  const lines = printed.mock.calls.map((call) => call.arguments.join(' '));
  assert.deepEqual(lines.filter((line) => line.startsWith('schedule ')), [`schedule heartbeat ran, key heartbeat@${due}`]);
});

test('a POST to the declared path without the platform\'s headers returns 404 scheduled_handler_only', async () => {
  const response = await fetch(`${base}${heartbeat.path}`, { method: 'POST' });
  assert.equal(response.status, 404);
  assert.equal((await response.json()).error, 'scheduled_handler_only');
});