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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10041-a-manifest-can-declare-an-adapter-with-i → main 1 commit fork

  • Merged. torque/10041-a-manifest-can-declare-an-adapter-with-i was merged into main as 490e78b.

Review

Sign in to review this change.
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`
544544 (up to 4 MiB) are rewritten; JSON, images, compressed or chunked HTML and
545545 `HEAD` requests pass through byte for byte. `"toolbar": false` keeps it out.
546546
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
547553 Two more fields say what the app's code may use (rule 9):
548554
549555 ```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";
1616 import type { DeclaredLimits } from "./contain.ts";
1717 import { isPublicAddress } from "./egress.ts";
1818 import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts";
19 import { parseAdapter, type AdapterSpec } from "./adapter.ts";
1920
2021 export interface AppManifest {
2122 /** The command that serves on `$PORT`. Run through `sh -c`, `exec`-ed. */
@@ -70,6 +71,11 @@ export interface AppManifest {
7071 * out of the app's HTML pages. Unset is on.
7172 */
7273 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;
7379 }
7480
7581 export type ManifestError =
@@ -168,6 +174,8 @@ export function parseManifest(
168174 const toolbar = parsed.value["toolbar"];
169175 if (toolbar !== undefined && typeof toolbar !== "boolean")
170176 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);
171179 const kind = app["kind"];
172180 if (kind !== undefined && kind !== "web" && kind !== "service")
173181 return invalid('forge.json: app.kind must be "web" or "service"');
@@ -200,6 +208,7 @@ export function parseManifest(
200208 egress: egress.value,
201209 limits: limits.value,
202210 toolbar: toolbar ?? true,
211 ...(adapter.value === undefined ? {} : { adapter: adapter.value }),
203212 });
204213 }
205214
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs