sproutboat 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -1
- package/README.md +114 -13
- package/SURFACE.md +40 -20
- package/package.json +7 -7
- package/src/account-field.ts +69 -0
- package/src/build.ts +12 -1
- package/src/cloud-deploy.ts +752 -0
- package/src/cloud-fake.ts +633 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +16 -3
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +100 -0
- package/src/main.ts +1184 -81
- package/src/options.ts +233 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +187 -59
- package/src/target.ts +95 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/** An in-process stand-in for the Sproutboat Cloud API, exactly the routes the CLI calls. Used by tests only. */
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import { parseBody } from "./cloud";
|
|
6
|
+
import { isString, jsonObject, type JsonObject } from "./json";
|
|
7
|
+
import { splitSql } from "./sql-split";
|
|
8
|
+
|
|
9
|
+
export const FAKE_KEY = "sbk_fakekey0123456789";
|
|
10
|
+
|
|
11
|
+
export type FakeOptions = {
|
|
12
|
+
/** Replies to POST /v1/device/token, in order; the last one repeats. */
|
|
13
|
+
tokenReplies?: { status: number; body: JsonObject }[];
|
|
14
|
+
role?: string;
|
|
15
|
+
applyStates?: string[];
|
|
16
|
+
existingAssetsRef?: string;
|
|
17
|
+
appExists?: boolean;
|
|
18
|
+
/** Fail every authenticated call with this status. */
|
|
19
|
+
failWith?: { status: number; body: JsonObject };
|
|
20
|
+
/** A team key: PUT /v1/modules/<sha> is refused (403 module-upload-disabled), as on the real Cloud. */
|
|
21
|
+
teamKey?: boolean;
|
|
22
|
+
/** The team's other Cloud apps, by project name, for GET /v1/apps and service binding callees. Ids are `<project>_<owner suffix>`. */
|
|
23
|
+
otherApps?: string[];
|
|
24
|
+
/** Permissions the key lacks: `secret:write`, `secret:list`, `data:read` and `data:write` answer 403. */
|
|
25
|
+
denyPermissions?: string[];
|
|
26
|
+
/** Secrets the app already holds (the app must exist). */
|
|
27
|
+
existingSecrets?: Record<string, string>;
|
|
28
|
+
/** D1 databases the app's host holds (binding names): POST /v1/apps/<p>/d1/<BINDING>/query runs on a real SQLite. */
|
|
29
|
+
d1?: string[];
|
|
30
|
+
/** Every data route (D1 query, R2 object) answers this instead of working: the status, the JSON body, extra headers. */
|
|
31
|
+
dataFailure?: { status: number; body?: JsonObject; headers?: Record<string, string> };
|
|
32
|
+
/** R2 buckets the app's host holds (binding names): /v1/apps/<p>/r2/<BUCKET>/objects/<key> stores objects in memory. */
|
|
33
|
+
r2?: string[];
|
|
34
|
+
/** How POST /v1/apps/<project>/builds behaves. */
|
|
35
|
+
build?: FakeBuildOptions;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type FakeBuildOptions = {
|
|
39
|
+
/**
|
|
40
|
+
* ready: 202 queued, then built. cached: 200 ready at once. failed / memory-cap: built and failed. hang: queued and
|
|
41
|
+
* never finishes (until cancelled). disabled / no-builder: 503. queue-full: 429.
|
|
42
|
+
*/
|
|
43
|
+
mode?: "ready" | "cached" | "failed" | "memory-cap" | "hang" | "disabled" | "no-builder" | "queue-full";
|
|
44
|
+
logs?: string[];
|
|
45
|
+
position?: number | null;
|
|
46
|
+
/** The queued state carries this `waiting` text (builder holding work back), then the build starts. */
|
|
47
|
+
waiting?: string;
|
|
48
|
+
wallMs?: number;
|
|
49
|
+
peakMemoryBytes?: number | null;
|
|
50
|
+
/** Drop the first events connection after this many log frames. */
|
|
51
|
+
dropAfterLogs?: number;
|
|
52
|
+
/** Refuse the events route altogether, so the client has to poll. */
|
|
53
|
+
noEvents?: boolean;
|
|
54
|
+
errorMessage?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const MiB = 1048576;
|
|
58
|
+
export const FAKE_OWNER_SUFFIX = "0123456789ab";
|
|
59
|
+
export const fakeAppId = (project: string) => `${project}_${FAKE_OWNER_SUFFIX}`;
|
|
60
|
+
|
|
61
|
+
export function startFakeCloud(options: FakeOptions = {}) {
|
|
62
|
+
const calls: { method: string; path: string; body: string; auth: string | null }[] = [];
|
|
63
|
+
const modules = new Set<string>();
|
|
64
|
+
const blobs = new Set<string>();
|
|
65
|
+
const manifests = new Set<string>();
|
|
66
|
+
const replies = [...(options.tokenReplies ?? [])];
|
|
67
|
+
let tokenPolls = 0;
|
|
68
|
+
let revoked = false;
|
|
69
|
+
let appExists = options.appExists ?? false;
|
|
70
|
+
let generation = 0;
|
|
71
|
+
let lastProject = "hello";
|
|
72
|
+
let applies = [...(options.applyStates ?? ["applied"])];
|
|
73
|
+
let spec: JsonObject = {};
|
|
74
|
+
const secretStore = new Map<string, string>(Object.entries(options.existingSecrets ?? {}));
|
|
75
|
+
const written: JsonObject[] = [];
|
|
76
|
+
const patches: JsonObject[] = [];
|
|
77
|
+
const denied = new Set(options.denyPermissions ?? []);
|
|
78
|
+
const dataFailureReply = (failure: NonNullable<FakeOptions["dataFailure"]>) =>
|
|
79
|
+
Response.json(failure.body ?? {}, { status: failure.status, headers: failure.headers });
|
|
80
|
+
const dataDenied = (need: string) =>
|
|
81
|
+
Response.json({ error: "forbidden", reason: "forbidden", need }, { status: 403 });
|
|
82
|
+
const permissionDenied = (need: string) => Response.json({ error: "forbidden", need }, { status: 403 });
|
|
83
|
+
/** The Cloud's spec rules for service bindings and secrets, then the merge. Returns an error reply or null. */
|
|
84
|
+
const checkAndStore = (sent: JsonObject, project: string): Response | null => {
|
|
85
|
+
const patch = jsonObject(sent.secrets_patch ?? null);
|
|
86
|
+
if (sent.secrets !== undefined && patch)
|
|
87
|
+
return Response.json({ error: "secrets and secrets_patch are exclusive" }, { status: 400 });
|
|
88
|
+
if ((patch || sent.secrets !== undefined) && denied.has("secret:write")) return permissionDenied("secret:write");
|
|
89
|
+
const bindings = Array.isArray(sent.bindings) ? sent.bindings.map((b) => jsonObject(b)) : [];
|
|
90
|
+
const services = jsonObject(sent.services ?? null) ?? {};
|
|
91
|
+
for (const b of bindings)
|
|
92
|
+
if (b?.kind === "service" && !(String(b.name) in services))
|
|
93
|
+
return Response.json({ error: `service binding ${String(b.name)} has no services entry` }, { status: 400 });
|
|
94
|
+
for (const [name, callee] of Object.entries(services)) {
|
|
95
|
+
if (!bindings.some((b) => b?.name === name && b.kind === "service"))
|
|
96
|
+
return Response.json({ error: `${name} is not a service binding` }, { status: 400 });
|
|
97
|
+
if (!String(callee).endsWith(`_${FAKE_OWNER_SUFFIX}`))
|
|
98
|
+
return Response.json({ error: "callee belongs to another owner" }, { status: 400 });
|
|
99
|
+
if (callee === fakeAppId(project)) return Response.json({ error: "an app cannot bind itself" }, { status: 400 });
|
|
100
|
+
}
|
|
101
|
+
written.push({ ...sent });
|
|
102
|
+
if (sent.secrets === null) secretStore.clear();
|
|
103
|
+
else if (jsonObject(sent.secrets ?? null)) {
|
|
104
|
+
secretStore.clear();
|
|
105
|
+
for (const [k, v] of Object.entries(jsonObject(sent.secrets)!)) secretStore.set(k, String(v));
|
|
106
|
+
}
|
|
107
|
+
if (patch) {
|
|
108
|
+
for (const [k, v] of Object.entries(jsonObject(patch.set ?? null) ?? {})) secretStore.set(k, String(v));
|
|
109
|
+
for (const k of Array.isArray(patch.delete) ? patch.delete : []) secretStore.delete(String(k));
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
const stored = (sent: JsonObject): JsonObject => {
|
|
114
|
+
const { secrets: _a, secrets_patch: _b, ...rest } = sent;
|
|
115
|
+
return rest;
|
|
116
|
+
};
|
|
117
|
+
if (options.existingAssetsRef) spec.assets_manifest_ref = options.existingAssetsRef;
|
|
118
|
+
|
|
119
|
+
const databases = new Map<string, Database>((options.d1 ?? []).map((name) => [name, new Database(":memory:")]));
|
|
120
|
+
const d1Requests: JsonObject[] = [];
|
|
121
|
+
const buckets = new Set(options.r2 ?? []);
|
|
122
|
+
const objects = new Map<string, { bytes: Uint8Array<ArrayBuffer>; headers: Record<string, string>; etag: string }>();
|
|
123
|
+
const putRequests: { key: string; headers: Record<string, string> }[] = [];
|
|
124
|
+
const sources = new Map<string, Uint8Array>();
|
|
125
|
+
const buildRequests: JsonObject[] = [];
|
|
126
|
+
const eventRequests: string[] = [];
|
|
127
|
+
const cancels: string[] = [];
|
|
128
|
+
const builds = new Map<string, FakeBuild>();
|
|
129
|
+
const buildOptions = options.build ?? {};
|
|
130
|
+
let buildCount = 0;
|
|
131
|
+
let dropped = false;
|
|
132
|
+
const mode = buildOptions.mode ?? "ready";
|
|
133
|
+
const logs = buildOptions.logs ?? [
|
|
134
|
+
"porffor: compiled in 1.0 s\n",
|
|
135
|
+
"built in 4.1 s, peak memory 742 MB (cap 1200 MB)\n",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
type FakeBuild = {
|
|
139
|
+
id: string;
|
|
140
|
+
body: JsonObject;
|
|
141
|
+
state: string;
|
|
142
|
+
cached: boolean;
|
|
143
|
+
error: JsonObject | null;
|
|
144
|
+
generation: number | null;
|
|
145
|
+
polls: number;
|
|
146
|
+
};
|
|
147
|
+
const stats = (): JsonObject => ({
|
|
148
|
+
driver: "fake",
|
|
149
|
+
wall_ms: buildOptions.wallMs ?? 4100,
|
|
150
|
+
cpu_ms: 3900,
|
|
151
|
+
peak_memory_bytes:
|
|
152
|
+
buildOptions.peakMemoryBytes === undefined
|
|
153
|
+
? (mode === "memory-cap" ? 1200 : 742) * MiB
|
|
154
|
+
: buildOptions.peakMemoryBytes,
|
|
155
|
+
memory_cap_bytes: 1200 * MiB,
|
|
156
|
+
module_bytes: 1000,
|
|
157
|
+
});
|
|
158
|
+
const view = (b: FakeBuild): JsonObject => ({
|
|
159
|
+
id: b.id,
|
|
160
|
+
project: "hello",
|
|
161
|
+
state: b.state,
|
|
162
|
+
cached: b.cached,
|
|
163
|
+
waiting: b.state === "queued" ? (buildOptions.waiting ?? null) : null,
|
|
164
|
+
position: b.state === "queued" ? (buildOptions.position === undefined ? 1 : buildOptions.position) : null,
|
|
165
|
+
error: b.error,
|
|
166
|
+
module_sha256: b.state === "ready" ? "f".repeat(64) : null,
|
|
167
|
+
record_generation: b.generation,
|
|
168
|
+
hosts: jsonObject(b.body.spec ?? null)?.hosts ?? [],
|
|
169
|
+
input_hash: "1".repeat(64),
|
|
170
|
+
queued_at: `2026-11-11T10:00:0${b.id.slice(-1)}.000Z`,
|
|
171
|
+
finished_at:
|
|
172
|
+
b.state === "ready" || b.state === "failed" || b.state === "canceled"
|
|
173
|
+
? `2026-11-11T10:00:1${b.id.slice(-1)}.000Z`
|
|
174
|
+
: null,
|
|
175
|
+
stats: (b.state === "ready" && !b.cached) || b.state === "failed" ? stats() : null,
|
|
176
|
+
});
|
|
177
|
+
/** The Cloud writes the app when a build is ready: a new record generation. */
|
|
178
|
+
const finish = (b: FakeBuild) => {
|
|
179
|
+
if (b.state !== "queued" && b.state !== "building") return;
|
|
180
|
+
if (mode === "failed" || mode === "memory-cap") {
|
|
181
|
+
b.state = "failed";
|
|
182
|
+
b.error = {
|
|
183
|
+
code: mode === "memory-cap" ? "memory-cap" : "compile-failed",
|
|
184
|
+
message:
|
|
185
|
+
buildOptions.errorMessage ??
|
|
186
|
+
(mode === "memory-cap"
|
|
187
|
+
? "porffor stopped: hit the 1200 MB memory cap"
|
|
188
|
+
: "porffor: unsupported syntax at index.js:3"),
|
|
189
|
+
};
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
b.state = "ready";
|
|
193
|
+
const sent = jsonObject(b.body.spec ?? null) ?? {};
|
|
194
|
+
const refused = checkAndStore(sent, String(jsonObject(b.body.build ?? null)?.name ?? ""));
|
|
195
|
+
if (refused) {
|
|
196
|
+
b.state = "failed";
|
|
197
|
+
b.error = { code: "spec-refused", message: "the Cloud refused the app spec" };
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
spec = { ...stored(sent), module: { sha256: "f".repeat(64) } };
|
|
201
|
+
appExists = true;
|
|
202
|
+
generation++;
|
|
203
|
+
b.generation = generation;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const server = Bun.serve({
|
|
207
|
+
hostname: "127.0.0.1",
|
|
208
|
+
port: 0,
|
|
209
|
+
async fetch(request) {
|
|
210
|
+
const url = new URL(request.url);
|
|
211
|
+
const raw = new Uint8Array(await request.arrayBuffer());
|
|
212
|
+
const body = new TextDecoder().decode(raw);
|
|
213
|
+
const path = url.pathname;
|
|
214
|
+
calls.push({
|
|
215
|
+
method: request.method,
|
|
216
|
+
path,
|
|
217
|
+
body:
|
|
218
|
+
path.startsWith("/v1/modules/") ||
|
|
219
|
+
path.startsWith("/v1/assets/blobs/") ||
|
|
220
|
+
(path.startsWith("/v1/sources/") && request.method === "PUT")
|
|
221
|
+
? ""
|
|
222
|
+
: body,
|
|
223
|
+
auth: request.headers.get("authorization"),
|
|
224
|
+
});
|
|
225
|
+
if (path === "/v1/config") return Response.json({ ingress: "public", github: false, public_url: null });
|
|
226
|
+
if (path === "/v1/device/code")
|
|
227
|
+
return Response.json({
|
|
228
|
+
device_code: "dc-secret",
|
|
229
|
+
user_code: "ABCD-EFGH",
|
|
230
|
+
verification_uri: `${url.origin}/device`,
|
|
231
|
+
verification_uri_complete: `${url.origin}/device?code=ABCD-EFGH`,
|
|
232
|
+
expires_in: 600,
|
|
233
|
+
interval: 5,
|
|
234
|
+
});
|
|
235
|
+
if (path === "/v1/device/token") {
|
|
236
|
+
tokenPolls++;
|
|
237
|
+
const next = replies.length > 1 ? replies.shift()! : replies[0];
|
|
238
|
+
if (next) return Response.json(next.body, { status: next.status });
|
|
239
|
+
return Response.json({
|
|
240
|
+
access_token: FAKE_KEY,
|
|
241
|
+
token_type: "Bearer",
|
|
242
|
+
key_id: "key_1",
|
|
243
|
+
key_name: "CLI on testbox",
|
|
244
|
+
organization: { id: "org_1", slug: "acme", name: "Acme Inc" },
|
|
245
|
+
role: "developer",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const bearer = request.headers.get("authorization")?.replace(/^Bearer /, "");
|
|
249
|
+
if (bearer !== FAKE_KEY || revoked)
|
|
250
|
+
return Response.json({ error: "invalid, expired or revoked API key" }, { status: 401 });
|
|
251
|
+
if (options.failWith) return Response.json(options.failWith.body, { status: options.failWith.status });
|
|
252
|
+
if (path === "/v1/whoami")
|
|
253
|
+
return Response.json({
|
|
254
|
+
kind: "apikey",
|
|
255
|
+
organization: "org_1",
|
|
256
|
+
organization_slug: "acme",
|
|
257
|
+
organization_name: "Acme Inc",
|
|
258
|
+
key: {
|
|
259
|
+
id: "key_1",
|
|
260
|
+
name: "CLI on testbox",
|
|
261
|
+
permissions: { app: ["read", "write", "delete_own"], secret: ["list", "write"] },
|
|
262
|
+
},
|
|
263
|
+
created_by: { id: "u1", name: "Robin", email: "robin@example.test" },
|
|
264
|
+
});
|
|
265
|
+
if (path === "/v1/keys/self" && request.method === "DELETE") {
|
|
266
|
+
revoked = true;
|
|
267
|
+
return Response.json({ revoked: true });
|
|
268
|
+
}
|
|
269
|
+
if (path === "/v1/assets/missing") {
|
|
270
|
+
const wanted: string[] = JSON.parse(body).sha256;
|
|
271
|
+
return Response.json({ missing: wanted.filter((h) => !blobs.has(h)) });
|
|
272
|
+
}
|
|
273
|
+
let m = /^\/v1\/assets\/blobs\/([0-9a-f]{64})$/.exec(path);
|
|
274
|
+
if (m && request.method === "PUT") {
|
|
275
|
+
blobs.add(m[1]!);
|
|
276
|
+
return Response.json({ stored: true });
|
|
277
|
+
}
|
|
278
|
+
m = /^\/v1\/assets\/manifests\/([0-9a-f]{64})$/.exec(path);
|
|
279
|
+
if (m && request.method === "PUT") {
|
|
280
|
+
if (createHash("sha256").update(body).digest("hex") !== m[1])
|
|
281
|
+
return Response.json({ error: "digest mismatch" }, { status: 400 });
|
|
282
|
+
manifests.add(m[1]!);
|
|
283
|
+
return Response.json({ ref: `assets/${m[1]}` });
|
|
284
|
+
}
|
|
285
|
+
if (path === "/v1/sources/missing" && request.method === "POST") {
|
|
286
|
+
const wanted: string[] = JSON.parse(body).sha256;
|
|
287
|
+
return Response.json({ missing: wanted.filter((h) => !sources.has(h)) });
|
|
288
|
+
}
|
|
289
|
+
m = /^\/v1\/sources\/([^/]+)$/.exec(path);
|
|
290
|
+
if (m && request.method === "PUT") {
|
|
291
|
+
if (raw.byteLength > 8 * MiB) return Response.json({ error: "source exceeds 8 MiB" }, { status: 413 });
|
|
292
|
+
if (createHash("sha256").update(raw).digest("hex") !== m[1])
|
|
293
|
+
return Response.json({ error: "digest mismatch" }, { status: 400 });
|
|
294
|
+
sources.set(m[1]!, raw);
|
|
295
|
+
return Response.json({ sha256: m[1], size: raw.byteLength, stored: true });
|
|
296
|
+
}
|
|
297
|
+
m = /^\/v1\/apps\/([^/]+)\/builds$/.exec(path);
|
|
298
|
+
if (m && request.method === "GET") {
|
|
299
|
+
const limit = Number(url.searchParams.get("limit") ?? 20);
|
|
300
|
+
return Response.json({ builds: [...builds.values()].reverse().slice(0, limit).map(view) });
|
|
301
|
+
}
|
|
302
|
+
if (m && request.method === "POST") {
|
|
303
|
+
const sent = JSON.parse(body);
|
|
304
|
+
if (mode === "disabled")
|
|
305
|
+
return Response.json(
|
|
306
|
+
{ error: "builds are not enabled on this Cloud", reason: "builds-disabled" },
|
|
307
|
+
{ status: 503 },
|
|
308
|
+
);
|
|
309
|
+
if (mode === "no-builder")
|
|
310
|
+
return Response.json({ error: "no builder is registered", reason: "no-builder" }, { status: 503 });
|
|
311
|
+
if (mode === "queue-full")
|
|
312
|
+
return Response.json(
|
|
313
|
+
{ error: "at most 2 builds at once per team; wait for one to finish", reason: "team-queue-full" },
|
|
314
|
+
{ status: 429 },
|
|
315
|
+
);
|
|
316
|
+
// The build spec is stored in plaintext, so the real Cloud refuses secrets in it.
|
|
317
|
+
const buildSpec = jsonObject(sent.spec ?? null) ?? {};
|
|
318
|
+
if ("secrets" in buildSpec || "secrets_patch" in buildSpec)
|
|
319
|
+
return Response.json({ error: "secrets do not belong in a build spec" }, { status: 400 });
|
|
320
|
+
if (!sources.has(sent.source_sha256))
|
|
321
|
+
return Response.json({ error: "source not uploaded", reason: "source-missing" }, { status: 409 });
|
|
322
|
+
buildRequests.push(sent);
|
|
323
|
+
lastProject = m[1]!;
|
|
324
|
+
const build: FakeBuild = {
|
|
325
|
+
id: `bld_${++buildCount}`,
|
|
326
|
+
body: sent,
|
|
327
|
+
state: "queued",
|
|
328
|
+
cached: mode === "cached",
|
|
329
|
+
error: null,
|
|
330
|
+
generation: null,
|
|
331
|
+
polls: 0,
|
|
332
|
+
};
|
|
333
|
+
builds.set(build.id, build);
|
|
334
|
+
if (mode === "cached") {
|
|
335
|
+
finish(build);
|
|
336
|
+
return Response.json(view(build), { status: 200 });
|
|
337
|
+
}
|
|
338
|
+
return Response.json(view(build), { status: 202 });
|
|
339
|
+
}
|
|
340
|
+
m = /^\/v1\/builds\/([^/]+)\/events$/.exec(path);
|
|
341
|
+
if (m && request.method === "GET") {
|
|
342
|
+
const build = builds.get(m[1]!);
|
|
343
|
+
if (!build) return Response.json({ error: "no such build" }, { status: 404 });
|
|
344
|
+
if (buildOptions.noEvents) return Response.json({ error: "events are down" }, { status: 502 });
|
|
345
|
+
eventRequests.push(url.search);
|
|
346
|
+
const after = Number(url.searchParams.get("after") ?? 0);
|
|
347
|
+
const encoder = new TextEncoder();
|
|
348
|
+
const drop = buildOptions.dropAfterLogs !== undefined && !dropped;
|
|
349
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
350
|
+
async start(controller) {
|
|
351
|
+
const send = (text: string) => controller.enqueue(encoder.encode(text));
|
|
352
|
+
const frame = (event: string, data: JsonObject, id?: number) =>
|
|
353
|
+
`${id === undefined ? "" : `id: ${id}\n`}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
354
|
+
send(
|
|
355
|
+
frame("state", {
|
|
356
|
+
state: "queued",
|
|
357
|
+
position: view(build).position,
|
|
358
|
+
waiting: buildOptions.waiting ?? null,
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
send(": keepalive\n\n");
|
|
362
|
+
send(frame("state", { state: "building", position: null, waiting: null }));
|
|
363
|
+
let seq = 0;
|
|
364
|
+
for (const text of logs) {
|
|
365
|
+
seq++;
|
|
366
|
+
if (seq <= after) continue;
|
|
367
|
+
const text1 = frame("log", { text }, seq);
|
|
368
|
+
// Split each frame across two chunks to exercise the client's buffering.
|
|
369
|
+
const cut = Math.floor(text1.length / 2);
|
|
370
|
+
send(text1.slice(0, cut));
|
|
371
|
+
await Bun.sleep(2);
|
|
372
|
+
send(text1.slice(cut));
|
|
373
|
+
if (drop && seq >= buildOptions.dropAfterLogs!) {
|
|
374
|
+
dropped = true;
|
|
375
|
+
controller.close();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (mode === "hang") return;
|
|
380
|
+
finish(build);
|
|
381
|
+
send(frame("done", view(build)));
|
|
382
|
+
controller.close();
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
return new Response(stream, { headers: { "content-type": "text/event-stream" } });
|
|
386
|
+
}
|
|
387
|
+
m = /^\/v1\/builds\/([^/]+)$/.exec(path);
|
|
388
|
+
if (m && request.method === "GET") {
|
|
389
|
+
const build = builds.get(m[1]!);
|
|
390
|
+
if (!build) return Response.json({ error: "no such build" }, { status: 404 });
|
|
391
|
+
if (mode !== "hang" && ++build.polls > 1) finish(build);
|
|
392
|
+
return Response.json(view(build));
|
|
393
|
+
}
|
|
394
|
+
if (m && request.method === "DELETE") {
|
|
395
|
+
const build = builds.get(m[1]!);
|
|
396
|
+
if (!build) return Response.json({ error: "no such build" }, { status: 404 });
|
|
397
|
+
cancels.push(build.id);
|
|
398
|
+
if (build.state === "queued" || build.state === "building") build.state = "canceled";
|
|
399
|
+
return Response.json(view(build));
|
|
400
|
+
}
|
|
401
|
+
m = /^\/v1\/modules\/([0-9a-f]{64})$/.exec(path);
|
|
402
|
+
if (m && request.method === "PUT") {
|
|
403
|
+
if (options.teamKey)
|
|
404
|
+
return Response.json(
|
|
405
|
+
{ error: "module-upload-disabled", message: "modules are built by the Cloud" },
|
|
406
|
+
{ status: 403 },
|
|
407
|
+
);
|
|
408
|
+
modules.add(m[1]!);
|
|
409
|
+
return Response.json({ sha256: m[1] });
|
|
410
|
+
}
|
|
411
|
+
if (path === "/v1/apps" && request.method === "GET") {
|
|
412
|
+
const apps = [...(options.otherApps ?? []), ...(appExists ? [lastProject] : [])];
|
|
413
|
+
return Response.json({
|
|
414
|
+
apps: [...new Set(apps)].map((project) => ({ project, app_id: fakeAppId(project), record_generation: 1 })),
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
m = /^\/v1\/apps\/([^/]+)\/d1\/([^/]+)\/query$/.exec(path);
|
|
418
|
+
if (m && request.method === "POST") {
|
|
419
|
+
if (options.dataFailure) return dataFailureReply(options.dataFailure);
|
|
420
|
+
if (!appExists) return Response.json({ error: "no such app" }, { status: 404 });
|
|
421
|
+
const db = databases.get(m[2]!);
|
|
422
|
+
if (!db) return Response.json({ error: "no such database", reason: "no-database" }, { status: 404 });
|
|
423
|
+
const sent = parseBody(body) ?? {};
|
|
424
|
+
d1Requests.push(sent);
|
|
425
|
+
const statements = (Array.isArray(sent.statements) ? sent.statements : []).flatMap((item) => {
|
|
426
|
+
const entry = jsonObject(item);
|
|
427
|
+
// SAFETY: the CLI sends only JSON scalars as bound parameters, which bun:sqlite binds directly.
|
|
428
|
+
const params = Array.isArray(entry?.params) ? (entry.params as (string | number | null)[]) : undefined;
|
|
429
|
+
return entry && isString(entry.sql) ? [{ sql: entry.sql, params }] : [];
|
|
430
|
+
});
|
|
431
|
+
if (denied.has("data:read")) return dataDenied("data:read");
|
|
432
|
+
if (
|
|
433
|
+
denied.has("data:write") &&
|
|
434
|
+
statements.some((entry) => splitSql(entry.sql).some((sql) => !/^\s*(select|pragma)\b/i.test(sql)))
|
|
435
|
+
)
|
|
436
|
+
return dataDenied("data:write");
|
|
437
|
+
const results: JsonObject[] = [];
|
|
438
|
+
let index = 0;
|
|
439
|
+
try {
|
|
440
|
+
db.run("BEGIN");
|
|
441
|
+
for (const [at, entry] of statements.entries()) {
|
|
442
|
+
index = at;
|
|
443
|
+
for (const sql of entry.params ? [entry.sql] : splitSql(entry.sql)) {
|
|
444
|
+
const q = db.query(sql);
|
|
445
|
+
// SAFETY: bun:sqlite rows are plain objects of SQLite values.
|
|
446
|
+
const rows = q.columnNames.length > 0 ? (q.all(...(entry.params ?? [])) as JsonObject[]) : [];
|
|
447
|
+
const info =
|
|
448
|
+
q.columnNames.length > 0 ? { changes: 0, lastInsertRowid: 0 } : q.run(...(entry.params ?? []));
|
|
449
|
+
results.push({
|
|
450
|
+
success: true,
|
|
451
|
+
results: rows,
|
|
452
|
+
meta: { changes: info.changes, last_row_id: Number(info.lastInsertRowid), duration: 0.1 },
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
db.run("COMMIT");
|
|
457
|
+
} catch (error) {
|
|
458
|
+
db.run("ROLLBACK");
|
|
459
|
+
return Response.json(
|
|
460
|
+
{ error: error instanceof Error ? error.message : String(error), reason: "sql-error", statement: index },
|
|
461
|
+
{ status: 400 },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return Response.json({ results });
|
|
465
|
+
}
|
|
466
|
+
m = /^\/v1\/apps\/([^/]+)\/r2\/([^/]+)\/objects\/(.+)$/.exec(path);
|
|
467
|
+
if (m) {
|
|
468
|
+
const method = request.method;
|
|
469
|
+
const invalid = (message: string, field = "key") =>
|
|
470
|
+
Response.json({ error: message, reason: "invalid", path: field }, { status: 400 });
|
|
471
|
+
const need = method === "GET" ? "data:read" : "data:write";
|
|
472
|
+
if (denied.has(need)) return dataDenied(need);
|
|
473
|
+
// The Cloud refuses an encoded dot, slash or backslash before decoding, and then the decoded key's own rules.
|
|
474
|
+
if (/%(2e|2f|5c)/i.test(m[3]!)) return invalid("the key has an encoded dot, slash or backslash");
|
|
475
|
+
let key: string;
|
|
476
|
+
try {
|
|
477
|
+
key = decodeURIComponent(m[3]!);
|
|
478
|
+
} catch {
|
|
479
|
+
return invalid("the key is not valid UTF-8");
|
|
480
|
+
}
|
|
481
|
+
const bytesLong = new TextEncoder().encode(key).length;
|
|
482
|
+
if (bytesLong < 1 || bytesLong > 1024) return invalid("the key must be 1 to 1024 bytes");
|
|
483
|
+
if (/[\u0000-\u001f\u007f-\u009f\\]/.test(key))
|
|
484
|
+
return invalid("the key has a control character or a backslash");
|
|
485
|
+
if (key.split("/").some((part) => part === "" || part === "." || part === ".."))
|
|
486
|
+
return invalid("the key has an empty, . or .. segment");
|
|
487
|
+
if (options.dataFailure) return dataFailureReply(options.dataFailure);
|
|
488
|
+
if (!appExists) return Response.json({ error: "no such app" }, { status: 404 });
|
|
489
|
+
if (!buckets.has(m[2]!))
|
|
490
|
+
return Response.json({ error: "no such bucket", reason: "no-bucket" }, { status: 404 });
|
|
491
|
+
if (method === "PUT") {
|
|
492
|
+
const declared = request.headers.get("content-length") ?? request.headers.get("x-declared-length");
|
|
493
|
+
putRequests.push({ key, headers: Object.fromEntries(request.headers.entries()) });
|
|
494
|
+
if (declared === null)
|
|
495
|
+
return Response.json({ error: "length required", reason: "length-required" }, { status: 411 });
|
|
496
|
+
if (!/^\d+$/.test(declared)) return invalid("the declared length is not a number", "body");
|
|
497
|
+
if (Number(declared) > 64 * 1024 * 1024)
|
|
498
|
+
return Response.json({ error: "too large", reason: "too-large" }, { status: 413 });
|
|
499
|
+
if (Number(declared) !== raw.byteLength)
|
|
500
|
+
return invalid("the body does not match its declared length", "body");
|
|
501
|
+
const headers: Record<string, string> = {};
|
|
502
|
+
for (const name of [
|
|
503
|
+
"content-type",
|
|
504
|
+
"content-disposition",
|
|
505
|
+
"content-encoding",
|
|
506
|
+
"content-language",
|
|
507
|
+
"expires",
|
|
508
|
+
]) {
|
|
509
|
+
const value = request.headers.get(name);
|
|
510
|
+
if (value !== null) headers[name] = value;
|
|
511
|
+
}
|
|
512
|
+
const cacheControl = request.headers.get("x-object-cache-control");
|
|
513
|
+
if (cacheControl !== null) headers["cache-control"] = cacheControl;
|
|
514
|
+
const etag = new Bun.CryptoHasher("sha256").update(raw).digest("hex").slice(0, 32);
|
|
515
|
+
objects.set(`${m[2]}/${key}`, { bytes: raw, headers, etag });
|
|
516
|
+
return Response.json({ key, size: raw.byteLength, etag: `"${etag}"` });
|
|
517
|
+
}
|
|
518
|
+
if (method === "DELETE") {
|
|
519
|
+
objects.delete(`${m[2]}/${key}`);
|
|
520
|
+
return Response.json({ key, deleted: true });
|
|
521
|
+
}
|
|
522
|
+
const found = objects.get(`${m[2]}/${key}`);
|
|
523
|
+
if (!found) return Response.json({ error: "no such object", reason: "no-object" }, { status: 404 });
|
|
524
|
+
// A GET never lets the browser interpret the object; its own metadata is only reported, as x-object-* headers.
|
|
525
|
+
const reply = new Headers({
|
|
526
|
+
"content-type": "application/octet-stream",
|
|
527
|
+
"x-content-type-options": "nosniff",
|
|
528
|
+
"content-security-policy": "sandbox",
|
|
529
|
+
"content-disposition": "attachment",
|
|
530
|
+
"cache-control": "no-store",
|
|
531
|
+
etag: `"${found.etag}"`,
|
|
532
|
+
});
|
|
533
|
+
for (const [name, value] of Object.entries(found.headers)) reply.set(`x-object-${name}`, value);
|
|
534
|
+
return new Response(found.bytes, { headers: reply });
|
|
535
|
+
}
|
|
536
|
+
m = /^\/v1\/apps\/([^/]+)\/secrets$/.exec(path);
|
|
537
|
+
if (m && request.method === "PATCH") {
|
|
538
|
+
if (denied.has("secret:write")) return permissionDenied("secret:write");
|
|
539
|
+
if (!appExists) return Response.json({ error: "no such app" }, { status: 404 });
|
|
540
|
+
const patch = JSON.parse(body);
|
|
541
|
+
for (const [k, v] of Object.entries(jsonObject(patch.set ?? null) ?? {})) secretStore.set(k, String(v));
|
|
542
|
+
for (const k of Array.isArray(patch.delete) ? patch.delete : []) secretStore.delete(String(k));
|
|
543
|
+
generation++;
|
|
544
|
+
patches.push(patch);
|
|
545
|
+
return Response.json({
|
|
546
|
+
app_id: fakeAppId(m[1]!),
|
|
547
|
+
record_generation: generation,
|
|
548
|
+
names: [...secretStore.keys()].sort(),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
m = /^\/v1\/apps\/([^/]+)\/secrets(?:\/([^/]+))?$/.exec(path);
|
|
552
|
+
if (m) {
|
|
553
|
+
const need = request.method === "GET" ? "secret:list" : "secret:write";
|
|
554
|
+
if (denied.has(need)) return permissionDenied(need);
|
|
555
|
+
if (!appExists) return Response.json({ error: "no such app" }, { status: 404 });
|
|
556
|
+
if (request.method === "GET" && !m[2]) return Response.json({ secrets: [...secretStore.keys()].sort() });
|
|
557
|
+
if (request.method === "PUT" && m[2]) {
|
|
558
|
+
secretStore.set(m[2], String(JSON.parse(body).value));
|
|
559
|
+
generation++;
|
|
560
|
+
return Response.json({ app_id: fakeAppId(m[1]!), record_generation: generation, name: m[2] });
|
|
561
|
+
}
|
|
562
|
+
if (request.method === "DELETE" && m[2]) {
|
|
563
|
+
if (!secretStore.delete(m[2])) return Response.json({ error: "no such secret" }, { status: 404 });
|
|
564
|
+
generation++;
|
|
565
|
+
return Response.json({ app_id: fakeAppId(m[1]!), record_generation: generation, name: m[2] });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
m = /^\/v1\/apps\/([^/]+)$/.exec(path);
|
|
569
|
+
if (m && request.method === "GET") {
|
|
570
|
+
if (!appExists) return Response.json({ error: "no such app" }, { status: 404 });
|
|
571
|
+
const state = applies.length > 1 ? applies.shift()! : applies[0]!;
|
|
572
|
+
return Response.json({
|
|
573
|
+
app_id: m[1],
|
|
574
|
+
record_generation: generation,
|
|
575
|
+
spec,
|
|
576
|
+
apply: {
|
|
577
|
+
state,
|
|
578
|
+
applied_generation: state === "applied" ? generation : null,
|
|
579
|
+
error: state === "failed" ? "module failed to load" : null,
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (m && request.method === "PUT") {
|
|
584
|
+
const sent = JSON.parse(body);
|
|
585
|
+
if (!modules.has(sent.module.sha256)) return Response.json({ error: "module not uploaded" }, { status: 400 });
|
|
586
|
+
const refused = checkAndStore(sent, m[1]!);
|
|
587
|
+
if (refused) return refused;
|
|
588
|
+
spec = stored(sent);
|
|
589
|
+
lastProject = m[1]!;
|
|
590
|
+
appExists = true;
|
|
591
|
+
generation++;
|
|
592
|
+
return Response.json({ app_id: m[1], record_generation: generation, host_id: "h1", placement_generation: 1 });
|
|
593
|
+
}
|
|
594
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
return {
|
|
598
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
599
|
+
calls,
|
|
600
|
+
modules,
|
|
601
|
+
blobs,
|
|
602
|
+
manifests,
|
|
603
|
+
sources,
|
|
604
|
+
buildRequests,
|
|
605
|
+
/** Bodies of POST /v1/apps/<app>/d1/<BINDING>/query. */
|
|
606
|
+
d1Requests,
|
|
607
|
+
/** Objects in the fake host's buckets, by `<BUCKET>/<key>`; `headers` are the stored metadata (content-type, cache-control, ...). */
|
|
608
|
+
objects,
|
|
609
|
+
/** Every PUT to an object route: the decoded key and the request headers. */
|
|
610
|
+
putRequests,
|
|
611
|
+
/** The fake host's databases, to look at what a command left behind. */
|
|
612
|
+
databases,
|
|
613
|
+
eventRequests,
|
|
614
|
+
cancels,
|
|
615
|
+
get tokenPolls() {
|
|
616
|
+
return tokenPolls;
|
|
617
|
+
},
|
|
618
|
+
get revoked() {
|
|
619
|
+
return revoked;
|
|
620
|
+
},
|
|
621
|
+
/** Every spec the Cloud accepted, secrets_patch and all (values are the test's own). */
|
|
622
|
+
written,
|
|
623
|
+
/** Bodies of PATCH /v1/apps/<app>/secrets. */
|
|
624
|
+
patches,
|
|
625
|
+
get secrets() {
|
|
626
|
+
return Object.fromEntries([...secretStore.entries()].sort());
|
|
627
|
+
},
|
|
628
|
+
get spec() {
|
|
629
|
+
return spec;
|
|
630
|
+
},
|
|
631
|
+
stop: () => server.stop(true),
|
|
632
|
+
};
|
|
633
|
+
}
|