Skip to content
410 linesCodeBlameRaw
1/**
2 * Container apps: a project built as a Docker image (crates/runner
3 * deploy/container.rs) runs on Cloudflare Containers behind a Worker of
4 * g1t's own, one per app, in the same Workers for Platforms namespace as
5 * every other app, so the `*.g1t.page` dispatcher (services/pages) and
6 * custom domains reach it by its script's name as they reach any app.
7 *
8 * Each app's Worker is `APP_WORKER` below, never the project's code: its
9 * Durable Object class `G1tApp` has the app's container (a Container
10 * application with the default scheduling policy, the app's image and
11 * instance type, at most one instance), starts it on the first request
12 * with the project's variables and secrets as its environment and `PORT`,
13 * waits for it to answer on its port, proxies requests to it, and stops it
14 * after `CONTAINER_SLEEP_MINUTES` without a request. While it runs it
15 * meters itself by the started minute, and reports to g1t with a token of
16 * its own (`POST /deployments/apps/<script>/meter`), which billing charges
17 * at cost plus the margin.
18 *
19 * The build's sandbox pushes the image with a login this service mints
20 * from Cloudflare's registry API: push only, for 15 minutes. Cloudflare's
21 * registry credentials cannot be held to one repository, so g1t holds the
22 * build to its app's: it names the repository, deploys only an image by
23 * digest in that repository, and checks the digest is there before it
24 * does. The login can never pull, so no build can read another app's image.
25 *
26 * Pure, with no Workers imports, so it can be tested under Node.
27 */
28
29import {
30 APP_INSTANCES,
31 CONTAINER_SLEEP_MINUTES,
32 DEFAULT_APP_INSTANCE,
33 appInstance,
34 containerSecondMicros,
35 type ContainerCosts,
36 type ContainerOffer,
37} from "@g1t/contracts";
38
39/** Cloudflare's container registry, which Containers pulls from. */
40export const REGISTRY_HOST = "registry.cloudflare.com";
41
42/** The Durable Object class of every container app's Worker. */
43export const APP_CLASS = "G1tApp";
44
45/** How long a build's push login lasts. */
46export const PUSH_LOGIN_MINUTES = 15;
47
48/** The repository an app's images are pushed to: `<account>/g1t-<script>`. */
49export function repositoryFor(account: string, script: string): string {
50 return `${account}/g1t-${script}`;
51}
52
53/** What `POST /accounts/{id}/containers/registries/{domain}/credentials` is asked for: a push-only login, for minutes. */
54export function pushLoginRequest(): { permissions: ["push"]; expiration_minutes: number } {
55 return { permissions: ["push"], expiration_minutes: PUSH_LOGIN_MINUTES };
56}
57
58/** The most an image may weigh to run on `instanceType`: its disk, as Cloudflare limits it. */
59export function maxImageBytes(instanceType: string): number {
60 return appInstance(instanceType).diskGb * 1_000_000_000;
61}
62
63/** An instance type the project may choose, or the default. */
64export function instanceTypeOf(value: unknown): string {
65 return typeof value === "string" && APP_INSTANCES.some((instance) => instance.type === value) ? value : DEFAULT_APP_INSTANCE;
66}
67
68/**
69 * What a build reported it pushed, if it is an image this build may run:
70 * a digest in its app's own repository and nothing else, with the port
71 * it listens on.
72 */
73export function pushedImage(
74 value: unknown,
75 repository: string,
76): { image: string; digest: string; port: number; sizeBytes: number } | null {
77 if (!value || typeof value !== "object") return null;
78 const report = value as Record<string, unknown>;
79 const image = typeof report.image === "string" ? report.image : "";
80 const match = image.match(/^([a-z0-9.-]+)\/(.+)@(sha256:[0-9a-f]{64})$/);
81 if (!match || match[1] !== REGISTRY_HOST || match[2] !== repository) return null;
82 const port = Number(report.port);
83 return {
84 image,
85 digest: match[3],
86 port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 8080,
87 sizeBytes: Math.max(0, Number(report.sizeBytes) || 0),
88 };
89}
90
91/** The settings an app's Worker reads, besides the project's variables and secrets. */
92export type AppWorkerSettings = {
93 mode: "serve" | "paused" | "down" | "redirect";
94 /** For `redirect`: the hostname to send visitors to. */
95 redirect?: string;
96 port: number;
97 instanceType: string;
98 /** Where it reports what it ran, with `meterToken`. */
99 meterUrl: string;
100 meterToken: string;
101};
102
103/** Names the app's Worker keeps for itself: never passed to the container. */
104export const INTERNAL_BINDINGS = ["APP", "G1T_MODE", "G1T_REDIRECT", "G1T_PORT", "G1T_SLEEP_MS", "G1T_METER_URL", "G1T_METER_TOKEN", "G1T_INSTANCE"];
105
106/**
107 * The upload's metadata for a container app's Worker: `APP_WORKER` as its
108 * one module, its Durable Object class with a container, g1t's settings,
109 * and the project's variables and secrets, which the Worker passes to the
110 * container as its environment. A variable named like one of g1t's own is
111 * left out.
112 */
113export function appWorkerMetadata(
114 settings: AppWorkerSettings,
115 runtime: { secrets: Record<string, string>; variables: Record<string, string> },
116 tags: string[],
117): Record<string, unknown> {
118 const own = new Set(INTERNAL_BINDINGS);
119 const bindings: object[] = [{ type: "durable_object_namespace", name: "APP", class_name: APP_CLASS }];
120 const plain: Record<string, string> = {
121 G1T_MODE: settings.mode,
122 G1T_PORT: String(settings.port),
123 G1T_SLEEP_MS: String(CONTAINER_SLEEP_MINUTES * 60 * 1000),
124 G1T_METER_URL: settings.meterUrl,
125 G1T_INSTANCE: settings.instanceType,
126 };
127 if (settings.redirect) plain.G1T_REDIRECT = settings.redirect;
128 for (const [name, text] of Object.entries(plain)) bindings.push({ type: "plain_text", name, text });
129 bindings.push({ type: "secret_text", name: "G1T_METER_TOKEN", text: settings.meterToken });
130 for (const [name, text] of Object.entries(runtime.variables)) {
131 if (!own.has(name) && !(name in runtime.secrets)) bindings.push({ type: "plain_text", name, text });
132 }
133 for (const [name, text] of Object.entries(runtime.secrets)) {
134 if (!own.has(name)) bindings.push({ type: "secret_text", name, text });
135 }
136 return {
137 main_module: "index.js",
138 compatibility_date: "2026-09-26",
139 compatibility_flags: [],
140 bindings,
141 tags,
142 // Declared, so uploading it again changes nothing about the class.
143 exports: { [APP_CLASS]: { type: "durable-object", storage: "sqlite" } },
144 containers: [{ class_name: APP_CLASS }],
145 };
146}
147
148/** `POST /accounts/{id}/containers/applications`: the app's container, for its Durable Object namespace. */
149export function applicationRequest(name: string, image: string, instanceType: string, namespaceId: string): Record<string, unknown> {
150 return {
151 name,
152 scheduling_policy: "default",
153 configuration: { image, instance_type: instanceTypeOf(instanceType) },
154 instances: 0,
155 // One instance per app: its Durable Object is named `app`.
156 max_instances: 1,
157 durable_objects: { namespace_id: namespaceId },
158 };
159}
160
161/** `POST /accounts/{id}/containers/applications/{app}/rollouts`: a new image or size, all at once. */
162export function rolloutRequest(image: string, instanceType: string, description: string): Record<string, unknown> {
163 return {
164 description,
165 strategy: "rolling",
166 kind: "full_auto",
167 step_percentage: 100,
168 target_configuration: { image, instance_type: instanceTypeOf(instanceType) },
169 };
170}
171
172/** The name of an app's Container application. */
173export function applicationName(script: string): string {
174 return `g1t-${script}`;
175}
176
177/** What an app's Worker reports: how long its container has run this month, and what it sent. */
178export type MeterReport = {
179 token: string;
180 /** The Durable Object's own counter: a fresh one starts again at zero. */
181 epoch: string;
182 month: string;
183 awakeSeconds: number;
184 egressBytes: number;
185 instanceType: string;
186};
187
188/** A report from an app's Worker, if it is one. */
189export function meterReport(body: unknown): MeterReport | null {
190 if (!body || typeof body !== "object") return null;
191 const b = body as Record<string, unknown>;
192 const awake = Number(b.awakeSeconds);
193 const egress = Number(b.egressBytes ?? 0);
194 if (typeof b.token !== "string" || typeof b.epoch !== "string" || !/^[\w-]{8,64}$/.test(b.epoch)) return null;
195 if (typeof b.month !== "string" || !/^\d{4}-\d{2}$/.test(b.month)) return null;
196 if (!Number.isFinite(awake) || awake < 0 || !Number.isFinite(egress) || egress < 0) return null;
197 // No month has more seconds than this.
198 if (awake > 31 * 24 * 60 * 60) return null;
199 return {
200 token: b.token,
201 epoch: b.epoch,
202 month: b.month,
203 awakeSeconds: Math.ceil(awake),
204 egressBytes: Math.ceil(egress),
205 instanceType: instanceTypeOf(b.instanceType),
206 };
207}
208
209/** One app's container time in a month, as the `container_meters` table keeps it. */
210export type ContainerMeter = { instance_type: string; awake_seconds: number; egress_bytes: number };
211
212/** What a month of container time cost g1t, in millionths of a dollar, rounded up, and in words. */
213export function containerCost(rows: ContainerMeter[], costs: ContainerCosts): { micros: number; seconds: number; detail: string | null } {
214 let micros = 0;
215 let seconds = 0;
216 let bytes = 0;
217 for (const row of rows) {
218 const awake = Math.max(0, row.awake_seconds);
219 micros += awake * containerSecondMicros(appInstance(row.instance_type), costs);
220 micros += (Math.max(0, row.egress_bytes) / 1_000_000_000) * costs.egressGb;
221 seconds += awake;
222 bytes += Math.max(0, row.egress_bytes);
223 }
224 const hours = Math.round((seconds / 3600) * 10) / 10;
225 const detail = seconds || bytes ? `${hours.toLocaleString("en-US")} container hours and ${(Math.round(bytes / 1e7) / 100).toLocaleString("en-US")} GB sent` : null;
226 return { micros: Math.ceil(micros), seconds, detail };
227}
228
229/** What each size costs an hour awake, at most, with `marginPercent` on top: for Settings, Deployments and the docs. */
230export function containerOffer(costs: ContainerCosts, marginPercent: number, available: boolean): ContainerOffer {
231 const withMargin = (micros: number) => Math.ceil(micros * (1 + marginPercent / 100));
232 return {
233 available,
234 prices: APP_INSTANCES.map((instance) => ({ ...instance, hourlyMicros: withMargin(containerSecondMicros(instance, costs) * 3600) })),
235 egressGbMicros: withMargin(costs.egressGb),
236 sleepMinutes: CONTAINER_SLEEP_MINUTES,
237 };
238}
239
240/**
241 * The Worker every container app runs as, g1t's own. `G1tApp` holds the
242 * container: it starts it with the project's variables and secrets and
243 * `PORT`, waits up to 90 seconds for it to answer on its port, proxies to
244 * it, and stops it once no request has come for `G1T_SLEEP_MS`. Every
245 * minute it is up is metered from its start, ahead (a minute begun is a
246 * minute charged), with the bytes it sent back, and reported to g1t.
247 */
248export const APP_WORKER = `import { DurableObject } from "cloudflare:workers";
249
250const INTERNAL = new Set(${JSON.stringify(INTERNAL_BINDINGS)});
251const TICK_MS = 60000;
252const READY_MS = 90000;
253
254function page(status, title, text) {
255 return new Response("<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width, initial-scale=1'><meta name=robots content=noindex><title>" + title + "</title><body style='font:16px system-ui;background:#121214;color:#ececf1;display:grid;place-items:center;min-height:100vh;margin:0;padding:0 16px'><main style='max-width:32rem'><h1>" + title + "</h1><p style='color:#9a9aa6'>" + text + "</p></main>", { status, headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" } });
256}
257
258/** What the app answers while it is not serving: paused, down, or moved. */
259function notServing(env, request) {
260 if (env.G1T_MODE === "redirect" && env.G1T_REDIRECT) {
261 const url = new URL(request.url);
262 return new Response(null, { status: 301, headers: { location: "https://" + env.G1T_REDIRECT + url.pathname + url.search, "cache-control": "public, max-age=3600" } });
263 }
264 if (env.G1T_MODE === "paused") return page(402, "This app is paused", "The workspace it belongs to reached its usage limit on g1t. It comes back by itself once the workspace is under it again.");
265 return page(404, "This app is not up", "It was taken down. Deploying it again brings it back.");
266}
267
268const monthOf = (at) => new Date(at).toISOString().slice(0, 7);
269
270export class ${APP_CLASS} extends DurableObject {
271 constructor(ctx, env) {
272 super(ctx, env);
273 this.listening = false;
274 this.lastRequest = Date.now();
275 this.sent = 0;
276 }
277
278 async fetch(request) {
279 const env = this.env;
280 const container = this.ctx.container;
281 if (env.G1T_MODE !== "serve") {
282 if (container && container.running) await container.destroy();
283 return notServing(env, request);
284 }
285 this.lastRequest = Date.now();
286 const port = Number(env.G1T_PORT) || 8080;
287 if (!container.running) {
288 const vars = { PORT: String(port) };
289 for (const [name, value] of Object.entries(env)) if (!INTERNAL.has(name) && typeof value === "string") vars[name] = value;
290 container.start({ env: vars, enableInternet: true });
291 this.listening = false;
292 await this.tick();
293 }
294 // A backstop: g1t stops it itself once it has slept long enough.
295 await container.setInactivityTimeout((Number(env.G1T_SLEEP_MS) || 300000) + 2 * TICK_MS).catch(() => undefined);
296 if (!(await this.ready(container, port))) {
297 return page(503, "The app did not start", "It is not answering on port " + port + ". It must listen on 0.0.0.0 at the port in the PORT environment variable (or the one its Dockerfile exposes). Its build's log on g1t has its output.");
298 }
299 let response;
300 try {
301 response = await container.getTcpPort(port).fetch(request);
302 } catch (error) {
303 return page(502, "The app failed", "It closed the connection before it answered.");
304 }
305 return this.counted(response);
306 }
307
308 /** Whether the container answers on its port, waiting for it to start. */
309 async ready(container, port) {
310 if (this.listening) return true;
311 const until = Date.now() + READY_MS;
312 while (Date.now() < until) {
313 try {
314 await container.getTcpPort(port).fetch("http://container/", { method: "HEAD" });
315 this.listening = true;
316 return true;
317 } catch {}
318 if (!container.running) return false;
319 await new Promise((resolve) => setTimeout(resolve, 250));
320 }
321 return false;
322 }
323
324 /** The response, with the bytes it sends counted. */
325 counted(response) {
326 if (!response.body) return response;
327 const self = this;
328 const count = new TransformStream({
329 transform(chunk, controller) {
330 self.sent += chunk.byteLength;
331 controller.enqueue(chunk);
332 },
333 });
334 return new Response(response.body.pipeThrough(count), response);
335 }
336
337 async meter(now) {
338 let meter = await this.ctx.storage.get("meter");
339 const instance = this.env.G1T_INSTANCE;
340 if (meter && meter.instance !== instance) {
341 // A new size is counted apart, since it costs differently.
342 await this.report(meter);
343 meter = null;
344 }
345 if (!meter) meter = { epoch: crypto.randomUUID(), instance, month: monthOf(now), awakeMs: 0, egressBytes: 0, billedUntil: 0 };
346 if (meter.month !== monthOf(now)) {
347 await this.report(meter);
348 meter = { ...meter, month: monthOf(now), awakeMs: 0, egressBytes: 0 };
349 }
350 return meter;
351 }
352
353 /** Charges the next minute up (a minute begun is charged), and wakes again at its end. */
354 async tick() {
355 const now = Date.now();
356 const meter = await this.meter(now);
357 const until = now + TICK_MS;
358 meter.awakeMs += until - Math.max(meter.billedUntil, now);
359 meter.billedUntil = until;
360 meter.egressBytes += this.sent;
361 this.sent = 0;
362 await this.ctx.storage.put("meter", meter);
363 await this.ctx.storage.setAlarm(until);
364 await this.report(meter);
365 }
366
367 async alarm() {
368 const container = this.ctx.container;
369 const sleep = Number(this.env.G1T_SLEEP_MS) || 300000;
370 if (container && container.running && this.env.G1T_MODE === "serve" && Date.now() - this.lastRequest < sleep) {
371 await this.tick();
372 return;
373 }
374 // Asleep: nothing runs, and nothing more is charged.
375 if (container && container.running) await container.destroy();
376 const meter = await this.meter(Date.now());
377 meter.egressBytes += this.sent;
378 this.sent = 0;
379 await this.ctx.storage.put("meter", meter);
380 await this.report(meter);
381 }
382
383 /** Tells g1t what has run this month; a report that fails is caught up by the next. */
384 async report(meter) {
385 await fetch(this.env.G1T_METER_URL, {
386 method: "POST",
387 headers: { "content-type": "application/json" },
388 body: JSON.stringify({
389 token: this.env.G1T_METER_TOKEN,
390 epoch: meter.epoch,
391 month: meter.month,
392 awakeSeconds: Math.ceil(meter.awakeMs / 1000),
393 egressBytes: meter.egressBytes,
394 instanceType: meter.instance,
395 }),
396 }).catch(() => undefined);
397 }
398}
399
400export default {
401 async fetch(request, env, ctx) {
402 if (env.G1T_MODE !== "serve") {
403 // Its Durable Object stops its container, if it is still up.
404 ctx.waitUntil(env.APP.get(env.APP.idFromName("app")).fetch(request).catch(() => undefined));
405 return notServing(env, request);
406 }
407 return env.APP.get(env.APP.idFromName("app")).fetch(request);
408 },
409};
410`;