Public: anyone can read this; the crew can change it.
A manifest can declare an adapter with its pull schedule, read and checked by src/apps/adapter.ts #10055
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10041-a-manifest-can-declare-an-adapter-with-i → main 1 commit fork
4 files
changed +168
−0
docs/APPS.md +6
| @@ -544,6 +544,12 @@ Only `text/html` responses the app sends uncompressed with a `Content-Length` | ||
| 544 | 544 | (up to 4 MiB) are rewritten; JSON, images, compressed or chunked HTML and |
| 545 | 545 | `HEAD` requests pass through byte for byte. `"toolbar": false` keeps it out. |
| 546 | 546 | |
| 547 | `adapter`, at the top level, says the app is an adapter the forge pulls | |
| 548 | from on a schedule (#437): `{ "adapter": { "schedule": { "every": 900 } } }`. | |
| 549 | `every` is whole seconds, 60 to 86400; any other key, at either level, fails | |
| 550 | the manifest with the field named (`src/apps/adapter.ts`). Absent, the app is | |
| 551 | not an adapter. | |
| 552 | ||
| 547 | 553 | Two more fields say what the app's code may use (rule 9): |
| 548 | 554 | |
| 549 | 555 | json |
src/apps/adapter.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * `adapter` (top level of `forge.json`): the app is an adapter, an app the | |
| 3 | * forge pulls from on a schedule (#437). The block says how often: | |
| 4 | * | |
| 5 | * { "adapter": { "schedule": { "every": 900 } } } | |
| 6 | * | |
| 7 | * `every` is whole seconds, 60 (one minute) to 86400 (one day). Pure: plain | |
| 8 | * data in, plain data or one sentence naming the field out. Unknown keys are | |
| 9 | * refused at every level, so a misspelt `scheduel` fails the manifest instead | |
| 10 | * of silently meaning "no schedule". | |
| 11 | */ | |
| 12 | ||
| 13 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 14 | ||
| 15 | export interface AdapterSpec { | |
| 16 | readonly schedule: { | |
| 17 | /** Seconds between pulls, an integer in [EVERY_MIN, EVERY_MAX]. */ | |
| 18 | readonly every: number; | |
| 19 | }; | |
| 20 | } | |
| 21 | ||
| 22 | /** One minute: more often than this is a poll loop, not a schedule. */ | |
| 23 | export const EVERY_MIN = 60; | |
| 24 | /** One day. */ | |
| 25 | export const EVERY_MAX = 86_400; | |
| 26 | ||
| 27 | type Json = Record<string, unknown>; | |
| 28 | ||
| 29 | const isObject = (value: unknown): value is Json => | |
| 30 | typeof value === "object" && value !== null && !Array.isArray(value); | |
| 31 | ||
| 32 | /** The keys `allowed` does not name, quoted, for the error sentence. */ | |
| 33 | function unknownKeys(value: Json, allowed: readonly string[]): string | undefined { | |
| 34 | const stray = Object.keys(value).filter((k) => !allowed.includes(k)); | |
| 35 | return stray.length === 0 ? undefined : stray.map((k) => JSON.stringify(k)).join(", "); | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * The parsed `adapter` block, `undefined` when it is absent. `raw` is the | |
| 40 | * value of the top-level `adapter` key exactly as `JSON.parse` gave it. | |
| 41 | */ | |
| 42 | export function parseAdapter(raw: unknown): Result<AdapterSpec | undefined, string> { | |
| 43 | if (raw === undefined) return ok(undefined); | |
| 44 | if (!isObject(raw)) return err("forge.json: adapter must be an object"); | |
| 45 | const strayTop = unknownKeys(raw, ["schedule"]); | |
| 46 | if (strayTop !== undefined) | |
| 47 | return err(`forge.json: adapter takes only schedule, not ${strayTop}`); | |
| 48 | const schedule = raw["schedule"]; | |
| 49 | if (schedule === undefined) return err("forge.json: adapter.schedule is required"); | |
| 50 | if (!isObject(schedule)) return err("forge.json: adapter.schedule must be an object"); | |
| 51 | const straySchedule = unknownKeys(schedule, ["every"]); | |
| 52 | if (straySchedule !== undefined) | |
| 53 | return err(`forge.json: adapter.schedule takes only every, not ${straySchedule}`); | |
| 54 | const every = schedule["every"]; | |
| 55 | if ( | |
| 56 | typeof every !== "number" || | |
| 57 | !Number.isInteger(every) || | |
| 58 | every < EVERY_MIN || | |
| 59 | every > EVERY_MAX | |
| 60 | ) | |
| 61 | return err( | |
| 62 | `forge.json: adapter.schedule.every must be a whole number of seconds, ${EVERY_MIN}–${EVERY_MAX}`, | |
| 63 | ); | |
| 64 | return ok({ schedule: { every } }); | |
| 65 | } |
src/apps/manifest.ts +9
| @@ -16,6 +16,7 @@ import { parseService, type ServiceSpec } from "./service.ts"; | ||
| 16 | 16 | import type { DeclaredLimits } from "./contain.ts"; |
| 17 | 17 | import { isPublicAddress } from "./egress.ts"; |
| 18 | 18 | import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts"; |
| 19 | import { parseAdapter, type AdapterSpec } from "./adapter.ts"; | |
| 19 | 20 | |
| 20 | 21 | export interface AppManifest { |
| 21 | 22 | /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */ |
| @@ -70,6 +71,11 @@ export interface AppManifest { | ||
| 70 | 71 | * out of the app's HTML pages. Unset is on. |
| 71 | 72 | */ |
| 72 | 73 | readonly toolbar?: boolean | undefined; |
| 74 | /** | |
| 75 | * `adapter` (top level of `forge.json`): the app is pulled from on a | |
| 76 | * schedule (`apps/adapter.ts`). Unset is not an adapter. | |
| 77 | */ | |
| 78 | readonly adapter?: AdapterSpec | undefined; | |
| 73 | 79 | } |
| 74 | 80 | |
| 75 | 81 | export type ManifestError = |
| @@ -168,6 +174,8 @@ export function parseManifest( | ||
| 168 | 174 | const toolbar = parsed.value["toolbar"]; |
| 169 | 175 | if (toolbar !== undefined && typeof toolbar !== "boolean") |
| 170 | 176 | return invalid("forge.json: toolbar must be true or false"); |
| 177 | const adapter = parseAdapter(parsed.value["adapter"]); | |
| 178 | if (!adapter.ok) return invalid(adapter.error); | |
| 171 | 179 | const kind = app["kind"]; |
| 172 | 180 | if (kind !== undefined && kind !== "web" && kind !== "service") |
| 173 | 181 | return invalid('forge.json: app.kind must be "web" or "service"'); |
| @@ -200,6 +208,7 @@ export function parseManifest( | ||
| 200 | 208 | egress: egress.value, |
| 201 | 209 | limits: limits.value, |
| 202 | 210 | toolbar: toolbar ?? true, |
| 211 | ...(adapter.value === undefined ? {} : { adapter: adapter.value }), | |
| 203 | 212 | }); |
| 204 | 213 | } |
| 205 | 214 |
test/apps-adapter.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { EVERY_MAX, EVERY_MIN, parseAdapter } from "../src/apps/adapter.ts"; | |
| 3 | import { parseManifest } from "../src/apps/manifest.ts"; | |
| 4 | ||
| 5 | const forge = (extra: Record<string, unknown>): string => | |
| 6 | JSON.stringify({ app: { run: "bun run src/server.ts" }, ...extra }); | |
| 7 | ||
| 8 | function refused(raw: unknown): string { | |
| 9 | const r = parseAdapter(raw); | |
| 10 | if (r.ok) throw new Error(`expected a refusal for ${JSON.stringify(raw)}`); | |
| 11 | return r.error; | |
| 12 | } | |
| 13 | ||
| 14 | describe("parseAdapter", () => { | |
| 15 | test("a valid block is plain data", () => { | |
| 16 | expect(parseAdapter({ schedule: { every: 900 } })).toEqual({ | |
| 17 | ok: true, | |
| 18 | value: { schedule: { every: 900 } }, | |
| 19 | }); | |
| 20 | }); | |
| 21 | ||
| 22 | test("the bounds are inclusive: 60 and 86400 seconds", () => { | |
| 23 | expect(EVERY_MIN).toBe(60); | |
| 24 | expect(EVERY_MAX).toBe(86_400); | |
| 25 | expect(parseAdapter({ schedule: { every: 60 } }).ok).toBe(true); | |
| 26 | expect(parseAdapter({ schedule: { every: 86_400 } }).ok).toBe(true); | |
| 27 | }); | |
| 28 | ||
| 29 | test("an absent block is undefined", () => { | |
| 30 | expect(parseAdapter(undefined)).toEqual({ ok: true, value: undefined }); | |
| 31 | }); | |
| 32 | ||
| 33 | test("every out of range is refused, naming the field", () => { | |
| 34 | for (const every of [59, 86_401, 0, -60]) { | |
| 35 | expect(refused({ schedule: { every } })).toBe( | |
| 36 | "forge.json: adapter.schedule.every must be a whole number of seconds, 60–86400", | |
| 37 | ); | |
| 38 | } | |
| 39 | }); | |
| 40 | ||
| 41 | test("every that is not an integer is refused", () => { | |
| 42 | for (const every of [90.5, "900", null, true, Number.NaN]) { | |
| 43 | expect(refused({ schedule: { every } })).toContain("adapter.schedule.every"); | |
| 44 | } | |
| 45 | expect(refused({ schedule: {} })).toContain("adapter.schedule.every"); | |
| 46 | }); | |
| 47 | ||
| 48 | test("unknown keys are refused at each level", () => { | |
| 49 | expect(refused({ schedule: { every: 900 }, url: "/pull" })).toBe( | |
| 50 | 'forge.json: adapter takes only schedule, not "url"', | |
| 51 | ); | |
| 52 | expect(refused({ schedule: { every: 900, jitter: 5 } })).toBe( | |
| 53 | 'forge.json: adapter.schedule takes only every, not "jitter"', | |
| 54 | ); | |
| 55 | expect(refused({ scheduel: { every: 900 } })).toContain('"scheduel"'); | |
| 56 | }); | |
| 57 | ||
| 58 | test("a block of the wrong shape is refused", () => { | |
| 59 | expect(refused(null)).toBe("forge.json: adapter must be an object"); | |
| 60 | expect(refused([])).toBe("forge.json: adapter must be an object"); | |
| 61 | expect(refused({})).toBe("forge.json: adapter.schedule is required"); | |
| 62 | expect(refused({ schedule: 900 })).toBe("forge.json: adapter.schedule must be an object"); | |
| 63 | }); | |
| 64 | }); | |
| 65 | ||
| 66 | describe("the manifest carries the adapter", () => { | |
| 67 | test("present: parsed onto the manifest", () => { | |
| 68 | const m = parseManifest(forge({ adapter: { schedule: { every: 3600 } } }), undefined); | |
| 69 | expect(m.ok && m.value.adapter).toEqual({ schedule: { every: 3600 } }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("absent: undefined", () => { | |
| 73 | const m = parseManifest(forge({}), undefined); | |
| 74 | expect(m.ok).toBe(true); | |
| 75 | expect(m.ok && m.value.adapter).toBeUndefined(); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a bad block fails the manifest and names the field", () => { | |
| 79 | const m = parseManifest(forge({ adapter: { schedule: { every: 30 } } }), undefined); | |
| 80 | expect(m).toEqual({ | |
| 81 | ok: false, | |
| 82 | error: { | |
| 83 | kind: "invalid", | |
| 84 | message: "forge.json: adapter.schedule.every must be a whole number of seconds, 60–86400", | |
| 85 | }, | |
| 86 | }); | |
| 87 | }); | |
| 88 | }); |