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