@alchemy.run/pkg 0.0.0-placeholder → 2.0.0-beta.78
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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +117 -0
- package/dist/Manifest.d.ts +107 -0
- package/dist/Manifest.d.ts.map +1 -0
- package/dist/Manifest.js +58 -0
- package/dist/Manifest.js.map +1 -0
- package/dist/Protocol.d.ts +186 -0
- package/dist/Protocol.d.ts.map +1 -0
- package/dist/Protocol.js +115 -0
- package/dist/Protocol.js.map +1 -0
- package/dist/Registry/Bindings.d.ts +134 -0
- package/dist/Registry/Bindings.d.ts.map +1 -0
- package/dist/Registry/Bindings.js +92 -0
- package/dist/Registry/Bindings.js.map +1 -0
- package/dist/Registry/GitHub.d.ts +96 -0
- package/dist/Registry/GitHub.d.ts.map +1 -0
- package/dist/Registry/GitHub.js +226 -0
- package/dist/Registry/GitHub.js.map +1 -0
- package/dist/Registry/Handler.d.ts +53 -0
- package/dist/Registry/Handler.d.ts.map +1 -0
- package/dist/Registry/Handler.js +375 -0
- package/dist/Registry/Handler.js.map +1 -0
- package/dist/Registry/KVCache.d.ts +24 -0
- package/dist/Registry/KVCache.d.ts.map +1 -0
- package/dist/Registry/KVCache.js +42 -0
- package/dist/Registry/KVCache.js.map +1 -0
- package/dist/Registry/Sweep.d.ts +12 -0
- package/dist/Registry/Sweep.d.ts.map +1 -0
- package/dist/Registry/Sweep.js +69 -0
- package/dist/Registry/Sweep.js.map +1 -0
- package/dist/Registry/Tags.d.ts +49 -0
- package/dist/Registry/Tags.d.ts.map +1 -0
- package/dist/Registry/Tags.js +80 -0
- package/dist/Registry/Tags.js.map +1 -0
- package/dist/Registry.d.ts +378 -0
- package/dist/Registry.d.ts.map +1 -0
- package/dist/Registry.js +119 -0
- package/dist/Registry.js.map +1 -0
- package/dist/cli/bin.d.ts +18 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +33 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/pack.d.ts +169 -0
- package/dist/cli/pack.d.ts.map +1 -0
- package/dist/cli/pack.js +447 -0
- package/dist/cli/pack.js.map +1 -0
- package/dist/cli/publish.d.ts +33 -0
- package/dist/cli/publish.d.ts.map +1 -0
- package/dist/cli/publish.js +76 -0
- package/dist/cli/publish.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/migrations/0001_init.sql +19 -0
- package/package.json +89 -1
- package/src/Manifest.ts +76 -0
- package/src/Protocol.ts +163 -0
- package/src/Registry/Bindings.ts +134 -0
- package/src/Registry/GitHub.ts +365 -0
- package/src/Registry/Handler.ts +549 -0
- package/src/Registry/KVCache.ts +68 -0
- package/src/Registry/Sweep.ts +117 -0
- package/src/Registry/Tags.ts +117 -0
- package/src/Registry.ts +197 -0
- package/src/cli/bin.ts +101 -0
- package/src/cli/pack.ts +603 -0
- package/src/cli/publish.ts +129 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import * as Cloudflare from "alchemy/Cloudflare";
|
|
2
|
+
import * as SQL from "alchemy/SQL/D1";
|
|
3
|
+
import { sha256 } from "alchemy/Util/sha256";
|
|
4
|
+
import * as Clock from "effect/Clock";
|
|
5
|
+
import * as Duration from "effect/Duration";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Encoding from "effect/Encoding";
|
|
8
|
+
import * as Layer from "effect/Layer";
|
|
9
|
+
import * as Path from "effect/Path";
|
|
10
|
+
import * as Result from "effect/Result";
|
|
11
|
+
import * as Schema from "effect/Schema";
|
|
12
|
+
import * as Etag from "effect/unstable/http/Etag";
|
|
13
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
14
|
+
import * as HttpPlatform from "effect/unstable/http/HttpPlatform";
|
|
15
|
+
import * as HttpRouter from "effect/unstable/http/HttpRouter";
|
|
16
|
+
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
|
|
17
|
+
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
|
|
18
|
+
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
|
|
19
|
+
import { ManifestJson, type ManifestPackage } from "../Manifest.ts";
|
|
20
|
+
import {
|
|
21
|
+
BadRequest,
|
|
22
|
+
Forbidden,
|
|
23
|
+
manifestArtifactName,
|
|
24
|
+
MissingTarballs,
|
|
25
|
+
PackageTooLarge,
|
|
26
|
+
PkgApi,
|
|
27
|
+
RunNotInProgress,
|
|
28
|
+
RunRef,
|
|
29
|
+
Upstream,
|
|
30
|
+
} from "../Protocol.ts";
|
|
31
|
+
import {
|
|
32
|
+
Bucket,
|
|
33
|
+
Cache,
|
|
34
|
+
COMMENT_MARKER,
|
|
35
|
+
Database,
|
|
36
|
+
RegistryConfig,
|
|
37
|
+
tarballKey,
|
|
38
|
+
} from "./Bindings.ts";
|
|
39
|
+
import * as GitHub from "./GitHub.ts";
|
|
40
|
+
import * as KVCache from "./KVCache.ts";
|
|
41
|
+
import { sweep } from "./Sweep.ts";
|
|
42
|
+
import * as Tags from "./Tags.ts";
|
|
43
|
+
|
|
44
|
+
/** What GitHub says about a run. */
|
|
45
|
+
const Run = Schema.Struct({
|
|
46
|
+
...RunRef.fields,
|
|
47
|
+
headSha: Schema.String,
|
|
48
|
+
headBranch: Schema.NullOr(Schema.String),
|
|
49
|
+
headRepo: Schema.String,
|
|
50
|
+
pr: Schema.NullOr(Schema.Number),
|
|
51
|
+
});
|
|
52
|
+
type Run = typeof Run.Type;
|
|
53
|
+
|
|
54
|
+
const bindings = Layer.mergeAll(
|
|
55
|
+
Cloudflare.R2.ReadWriteBucketBinding,
|
|
56
|
+
Cloudflare.D1.QueryDatabaseBinding,
|
|
57
|
+
Cloudflare.KV.ReadWriteNamespaceBinding,
|
|
58
|
+
Cloudflare.Workers.CronEventSourceLive,
|
|
59
|
+
FetchHttpClient.layer,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const SHORT = 7;
|
|
63
|
+
|
|
64
|
+
/** How long a resolved run is reused for uploads before asking GitHub again. */
|
|
65
|
+
const RUN_CACHE = Duration.minutes(1);
|
|
66
|
+
|
|
67
|
+
const CHECK_NAME = "Preview packages";
|
|
68
|
+
|
|
69
|
+
/** The public origin a request arrived on. */
|
|
70
|
+
const origin = Effect.map(
|
|
71
|
+
HttpServerRequest,
|
|
72
|
+
(request) => `https://${request.headers.host ?? "localhost"}`,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A run from a fork: its head commit belongs to another repository, so its
|
|
77
|
+
* workflow file, and therefore everything it packs, is the fork's.
|
|
78
|
+
*/
|
|
79
|
+
const isFork = (run: Run) => run.headRepo !== run.repo;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Tags every package in a publication receives, all derived from the run.
|
|
83
|
+
* Runs on the repository's own commits get the head commit, the short
|
|
84
|
+
* commit, `branch:<name>`, and `pr:N` for pull requests. A fork's run gets
|
|
85
|
+
* only `pr:N`: commit tags are shared by every publisher, and a fork can
|
|
86
|
+
* run any commit it likes, including one already published from the
|
|
87
|
+
* repository, so letting it write them would let it repoint them.
|
|
88
|
+
*/
|
|
89
|
+
export const tagsFor = (run: Run): string[] => {
|
|
90
|
+
if (isFork(run)) {
|
|
91
|
+
return run.pr === null ? [] : [`pr:${run.pr}`];
|
|
92
|
+
}
|
|
93
|
+
const tags = [run.headSha, run.headSha.slice(0, SHORT)];
|
|
94
|
+
if (run.pr !== null) tags.push(`pr:${run.pr}`);
|
|
95
|
+
if (run.headBranch) tags.push(`branch:${run.headBranch}`);
|
|
96
|
+
return tags;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The tag install commands are written against. */
|
|
100
|
+
export const installTag = (run: Run) =>
|
|
101
|
+
isFork(run) ? `pr:${run.pr}` : run.headSha.slice(0, SHORT);
|
|
102
|
+
|
|
103
|
+
const upstream = (e: { readonly _tag: string; readonly message?: string }) =>
|
|
104
|
+
new Upstream({ message: GitHub.describe(e) });
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Storage failures are not part of the protocol: they are defects, which
|
|
108
|
+
* the router answers with a 500 after logging the cause.
|
|
109
|
+
*/
|
|
110
|
+
const storageFailures = ["R2Error", "SqlError", "SchemaError"] as const;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the run a request names through GitHub; nothing about it is
|
|
114
|
+
* trusted from the client. Only allowed repositories are looked up at all,
|
|
115
|
+
* and the run must be in progress, since requests come from inside it.
|
|
116
|
+
*/
|
|
117
|
+
const lookupRun = Effect.fn("lookupRun")(function* (ref: RunRef) {
|
|
118
|
+
const { policy } = yield* RegistryConfig;
|
|
119
|
+
const github = yield* GitHub.GitHubApp;
|
|
120
|
+
|
|
121
|
+
if (!policy.repos.includes(ref.repo)) {
|
|
122
|
+
return yield* new Forbidden({ message: `${ref.repo} may not publish` });
|
|
123
|
+
}
|
|
124
|
+
const data = yield* github
|
|
125
|
+
.getRun(ref.repo, ref.runId)
|
|
126
|
+
.pipe(Effect.mapError(upstream));
|
|
127
|
+
if (
|
|
128
|
+
data.status !== "in_progress" ||
|
|
129
|
+
(data.run_attempt !== undefined && data.run_attempt !== ref.attempt)
|
|
130
|
+
) {
|
|
131
|
+
return yield* new RunNotInProgress({ message: "run is not in progress" });
|
|
132
|
+
}
|
|
133
|
+
if (data.event !== "push" && data.event !== "pull_request") {
|
|
134
|
+
return yield* new BadRequest({
|
|
135
|
+
message: `unsupported event ${data.event}`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const headRepo = data.head_repository?.full_name ?? data.repository.full_name;
|
|
139
|
+
let pr: number | null = null;
|
|
140
|
+
if (data.event === "pull_request") {
|
|
141
|
+
// The pull request whose head is this run's head, from the repository
|
|
142
|
+
// the run's head lives in. The same commit can head several pull
|
|
143
|
+
// requests, including one opened from a fork against a commit the
|
|
144
|
+
// repository already published; matching the head repository keeps a
|
|
145
|
+
// run's publication on its own pull request.
|
|
146
|
+
const pulls = yield* github
|
|
147
|
+
.pullRequestsForCommit(ref.repo, headRepo, data.head_sha)
|
|
148
|
+
.pipe(Effect.mapError(upstream));
|
|
149
|
+
const first = pulls[0];
|
|
150
|
+
if (first === undefined) {
|
|
151
|
+
return yield* new BadRequest({
|
|
152
|
+
message: `no pull request from ${headRepo} has head ${data.head_sha}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
pr = first.number;
|
|
156
|
+
} else if (headRepo !== ref.repo) {
|
|
157
|
+
return yield* new BadRequest({
|
|
158
|
+
message: `push run ${ref.runId} is for ${headRepo}, not ${ref.repo}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
...ref,
|
|
163
|
+
headSha: data.head_sha,
|
|
164
|
+
headBranch: data.head_branch,
|
|
165
|
+
headRepo,
|
|
166
|
+
pr,
|
|
167
|
+
} satisfies Run;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The proof. The job uploaded the manifest as an artifact of its run,
|
|
172
|
+
* named by the manifest's hash. Only the job can add artifacts to the
|
|
173
|
+
* run, and GitHub reports the run's artifacts to the App, so a matching
|
|
174
|
+
* name means this run vouched for exactly this manifest text.
|
|
175
|
+
*/
|
|
176
|
+
const requireVouched = Effect.fn("requireVouched")(function* (
|
|
177
|
+
run: Run,
|
|
178
|
+
manifestText: string,
|
|
179
|
+
) {
|
|
180
|
+
const github = yield* GitHub.GitHubApp;
|
|
181
|
+
const expected = manifestArtifactName(yield* sha256(manifestText));
|
|
182
|
+
const artifacts = yield* github
|
|
183
|
+
.listRunArtifacts(run.repo, run.runId, expected)
|
|
184
|
+
.pipe(Effect.mapError(upstream));
|
|
185
|
+
if (!artifacts.some((a) => a.name === expected && !a.expired)) {
|
|
186
|
+
return yield* new Forbidden({
|
|
187
|
+
message: `run ${run.runId} has not vouched for this manifest (no artifact ${expected})`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const publish = Effect.fn("publish")(function* (
|
|
193
|
+
run: Run,
|
|
194
|
+
manifestText: string,
|
|
195
|
+
) {
|
|
196
|
+
const { policy } = yield* RegistryConfig;
|
|
197
|
+
const r2 = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
|
|
198
|
+
const github = yield* GitHub.GitHubApp;
|
|
199
|
+
const base = yield* origin;
|
|
200
|
+
|
|
201
|
+
yield* requireVouched(run, manifestText);
|
|
202
|
+
const manifest = yield* Schema.decodeUnknownEffect(ManifestJson)(
|
|
203
|
+
manifestText,
|
|
204
|
+
).pipe(
|
|
205
|
+
Effect.mapError(
|
|
206
|
+
(e) => new BadRequest({ message: `invalid manifest: ${String(e)}` }),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
// The run vouched for the manifest, but the tags come from the run's
|
|
210
|
+
// head, so the packed checkout has to be that commit: a `pull_request`
|
|
211
|
+
// job that packs the synthetic merge commit would otherwise publish bytes
|
|
212
|
+
// built from a commit that exists nowhere under the commit's name.
|
|
213
|
+
if (manifest.head !== run.headSha) {
|
|
214
|
+
return yield* new BadRequest({
|
|
215
|
+
message: `manifest was packed at ${manifest.head}, but the run's head is ${run.headSha}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const packages = manifest.packages;
|
|
219
|
+
const maxSize = policy.maxPackageSize;
|
|
220
|
+
const tooLarge =
|
|
221
|
+
maxSize === undefined
|
|
222
|
+
? undefined
|
|
223
|
+
: packages.find((pkg) => BigInt(pkg.size) > maxSize);
|
|
224
|
+
if (tooLarge !== undefined) {
|
|
225
|
+
return yield* new PackageTooLarge({
|
|
226
|
+
message: `${tooLarge.name} exceeds ${maxSize} bytes`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const missing = yield* Effect.filter(
|
|
231
|
+
packages,
|
|
232
|
+
(pkg) =>
|
|
233
|
+
r2
|
|
234
|
+
.head(tarballKey(pkg.name, pkg.sha256))
|
|
235
|
+
.pipe(Effect.map((object) => object === null)),
|
|
236
|
+
{ concurrency: 8 },
|
|
237
|
+
).pipe(
|
|
238
|
+
Effect.map((packages) =>
|
|
239
|
+
packages.map(({ name, sha256 }) => ({ name, sha256 })),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
if (missing.length > 0) {
|
|
243
|
+
return yield* new MissingTarballs({ missing });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const now = yield* Clock.currentTimeMillis;
|
|
247
|
+
const expiresAt = now + Duration.toMillis(policy.ttl);
|
|
248
|
+
const prs = run.pr !== null ? [`${run.repo}#${run.pr}`] : [];
|
|
249
|
+
const tags = tagsFor(run);
|
|
250
|
+
const tag = installTag(run);
|
|
251
|
+
const published = yield* Effect.forEach(
|
|
252
|
+
packages,
|
|
253
|
+
Effect.fn(function* (pkg: ManifestPackage) {
|
|
254
|
+
yield* Effect.forEach(
|
|
255
|
+
tags,
|
|
256
|
+
(tag) =>
|
|
257
|
+
Tags.upsert({
|
|
258
|
+
package: pkg.name,
|
|
259
|
+
tag,
|
|
260
|
+
sha256: pkg.sha256,
|
|
261
|
+
expiresAt,
|
|
262
|
+
prs,
|
|
263
|
+
}),
|
|
264
|
+
{ discard: true },
|
|
265
|
+
);
|
|
266
|
+
return {
|
|
267
|
+
name: pkg.name,
|
|
268
|
+
group: pkg.group,
|
|
269
|
+
url: `${base}/${pkg.name}/${tag}`,
|
|
270
|
+
tags,
|
|
271
|
+
};
|
|
272
|
+
}),
|
|
273
|
+
{ concurrency: 4 },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// A check on the commit itself, so the install lines are visible on
|
|
277
|
+
// pushes and on fork pull requests alike.
|
|
278
|
+
yield* github
|
|
279
|
+
.createCheckRun(run.repo, {
|
|
280
|
+
headSha: run.headSha,
|
|
281
|
+
name: CHECK_NAME,
|
|
282
|
+
title: `${packages.length} package(s) published`,
|
|
283
|
+
summary: GitHub.renderInstalls(published, manifest.groups),
|
|
284
|
+
detailsUrl: base,
|
|
285
|
+
})
|
|
286
|
+
.pipe(
|
|
287
|
+
Effect.catch((e) =>
|
|
288
|
+
Effect.logWarning(
|
|
289
|
+
`check run on ${run.repo}@${run.headSha} failed: ${GitHub.describe(e)}`,
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
if (run.pr !== null) {
|
|
294
|
+
const body = GitHub.renderComment(published, manifest.groups, {
|
|
295
|
+
publishedAt: now,
|
|
296
|
+
expiresAt,
|
|
297
|
+
});
|
|
298
|
+
yield* github
|
|
299
|
+
.upsertComment(run.repo, run.pr, COMMENT_MARKER, body)
|
|
300
|
+
.pipe(
|
|
301
|
+
Effect.catch((e) =>
|
|
302
|
+
Effect.logWarning(
|
|
303
|
+
`comment on ${run.repo}#${run.pr} failed: ${GitHub.describe(e)}`,
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return { packages: published };
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Content-addressed upload, streamed straight into R2 with the hash
|
|
313
|
+
* verified by R2 itself. Untagged objects are swept after a day.
|
|
314
|
+
*/
|
|
315
|
+
const uploadTarball = Effect.fn("uploadTarball")(function* (
|
|
316
|
+
request: HttpServerRequest,
|
|
317
|
+
name: string,
|
|
318
|
+
sha256: string,
|
|
319
|
+
) {
|
|
320
|
+
const { policy } = yield* RegistryConfig;
|
|
321
|
+
const r2 = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
|
|
322
|
+
|
|
323
|
+
const contentLength = Number(request.headers["content-length"] ?? 0);
|
|
324
|
+
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
|
|
325
|
+
return yield* new BadRequest({ message: "Content-Length is required" });
|
|
326
|
+
}
|
|
327
|
+
const maxSize = policy.maxPackageSize;
|
|
328
|
+
if (maxSize !== undefined && BigInt(contentLength) > maxSize) {
|
|
329
|
+
return yield* new PackageTooLarge({
|
|
330
|
+
message: `tarball exceeds ${maxSize} bytes`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const key = tarballKey(name, sha256);
|
|
334
|
+
const existing = yield* r2.head(key);
|
|
335
|
+
if (existing !== null) {
|
|
336
|
+
return { name, sha256, size: existing.size, uploaded: false };
|
|
337
|
+
}
|
|
338
|
+
yield* r2
|
|
339
|
+
.put(key, request.stream, {
|
|
340
|
+
contentLength,
|
|
341
|
+
// The params schema already constrained this to 64 hex chars.
|
|
342
|
+
sha256: Result.getOrThrow(Encoding.decodeHex(sha256)),
|
|
343
|
+
})
|
|
344
|
+
.pipe(
|
|
345
|
+
Effect.mapError(
|
|
346
|
+
(e) => new BadRequest({ message: `upload rejected: ${String(e)}` }),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
return { name, sha256, size: contentLength, uploaded: true };
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Parse `/<name>/<tag>` and `/<name>/-/<sha256>.tgz`. Scoped names take
|
|
354
|
+
* two segments; the tag is everything after the name and may itself contain
|
|
355
|
+
* `/` and `:`.
|
|
356
|
+
*/
|
|
357
|
+
export const parseInstallPath = (
|
|
358
|
+
pathname: string,
|
|
359
|
+
scope: string | undefined,
|
|
360
|
+
):
|
|
361
|
+
| { kind: "tag"; name: string; tag: string }
|
|
362
|
+
| { kind: "tarball"; name: string; sha256: string }
|
|
363
|
+
| undefined => {
|
|
364
|
+
let path: string;
|
|
365
|
+
try {
|
|
366
|
+
path = decodeURIComponent(pathname.replace(/^\/+/, ""));
|
|
367
|
+
} catch {
|
|
368
|
+
// Malformed percent-encoding is a path nothing can be published under.
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const tarball = path.match(
|
|
372
|
+
/^(@[^/]+\/[^/@]+|[^/@]+)\/-\/([a-f0-9]{64})\.tgz$/,
|
|
373
|
+
);
|
|
374
|
+
if (tarball) {
|
|
375
|
+
return {
|
|
376
|
+
kind: "tarball",
|
|
377
|
+
name: qualify(tarball[1]!, scope),
|
|
378
|
+
sha256: tarball[2]!,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const segments = path.split("/");
|
|
382
|
+
const nameLength = path.startsWith("@") ? 2 : 1;
|
|
383
|
+
const name = segments.slice(0, nameLength).join("/");
|
|
384
|
+
const tag = segments.slice(nameLength).join("/");
|
|
385
|
+
if (segments.length <= nameLength || name === "" || tag === "") {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
return { kind: "tag", name: qualify(name, scope), tag };
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const qualify = (name: string, scope: string | undefined) =>
|
|
392
|
+
scope !== undefined && !name.startsWith("@") ? `${scope}/${name}` : name;
|
|
393
|
+
|
|
394
|
+
const notFound = (message: string) =>
|
|
395
|
+
HttpServerResponse.json({ error: message }, { status: 404 });
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Install URLs: `/<name>/<tag>` redirects to the immutable tarball URL,
|
|
399
|
+
* `/<name>/-/<sha256>.tgz` streams it from R2. Everything the API routes
|
|
400
|
+
* do not claim lands here.
|
|
401
|
+
*/
|
|
402
|
+
const install = Effect.gen(function* () {
|
|
403
|
+
const request = yield* HttpServerRequest;
|
|
404
|
+
const { aliases } = yield* RegistryConfig;
|
|
405
|
+
const r2 = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
|
|
406
|
+
|
|
407
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
408
|
+
return yield* HttpServerResponse.json(
|
|
409
|
+
{ error: "method not allowed" },
|
|
410
|
+
{ status: 405 },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const host = request.headers.host ?? "localhost";
|
|
414
|
+
const target = parseInstallPath(
|
|
415
|
+
new URL(request.url, `https://${host}`).pathname,
|
|
416
|
+
aliases[host],
|
|
417
|
+
);
|
|
418
|
+
if (target === undefined) {
|
|
419
|
+
return yield* notFound("not found");
|
|
420
|
+
}
|
|
421
|
+
if (target.kind === "tag") {
|
|
422
|
+
const sha256 = yield* Tags.resolve(target.name, target.tag);
|
|
423
|
+
if (sha256 === undefined) {
|
|
424
|
+
return yield* notFound(`${target.name}@${target.tag} not found`);
|
|
425
|
+
}
|
|
426
|
+
return HttpServerResponse.redirect(`/${target.name}/-/${sha256}.tgz`, {
|
|
427
|
+
status: 302,
|
|
428
|
+
headers: { "cache-control": "no-store" },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const object = yield* r2.get(tarballKey(target.name, target.sha256));
|
|
432
|
+
if (object === null) {
|
|
433
|
+
return yield* notFound("tarball not found");
|
|
434
|
+
}
|
|
435
|
+
const headers = {
|
|
436
|
+
"content-type": "application/gzip",
|
|
437
|
+
"content-length": String(object.size),
|
|
438
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
439
|
+
};
|
|
440
|
+
return request.method === "HEAD"
|
|
441
|
+
? HttpServerResponse.empty({ status: 200, headers })
|
|
442
|
+
: HttpServerResponse.stream(object.body, { status: 200, headers });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const banner = Effect.map(origin, (base) =>
|
|
446
|
+
HttpServerResponse.text(
|
|
447
|
+
`Preview package registry. Install with: bun add ${base}/<package>/<commit|branch:name|pr:N>\n`,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The registry never serves files, so the HTTP platform's file surface is
|
|
453
|
+
* stubbed rather than pulling a file system into the Worker.
|
|
454
|
+
*/
|
|
455
|
+
const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, {
|
|
456
|
+
platform: "web",
|
|
457
|
+
compression: {
|
|
458
|
+
algorithms: new Set<HttpPlatform.CompressionAlgorithm>(),
|
|
459
|
+
compressResponse: (response) => Effect.succeed(response),
|
|
460
|
+
},
|
|
461
|
+
fileResponse: () => Effect.die("fileResponse is not supported"),
|
|
462
|
+
fileWebResponse: () => Effect.die("fileWebResponse is not supported"),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The registry Worker's Init phase. Runs at plan time to register bindings
|
|
467
|
+
* and the cron trigger, and once per isolate at runtime to build the
|
|
468
|
+
* router. Everything it needs comes from {@link RegistryConfig}.
|
|
469
|
+
*/
|
|
470
|
+
export const handler = Effect.gen(function* () {
|
|
471
|
+
const config = yield* RegistryConfig;
|
|
472
|
+
// Binding the capabilities here is what registers the R2, D1, and KV
|
|
473
|
+
// bindings on the Worker at plan time. Handlers bind them again per event,
|
|
474
|
+
// which at runtime is just a lookup in the Worker's environment.
|
|
475
|
+
yield* Cloudflare.R2.ReadWriteBucket(Bucket);
|
|
476
|
+
yield* Cloudflare.KV.ReadWriteNamespace(Cache);
|
|
477
|
+
const d1 = yield* Cloudflare.D1.QueryDatabase(Database);
|
|
478
|
+
|
|
479
|
+
// Built once per isolate. The D1 client memoizes its connection on each
|
|
480
|
+
// event's scope, so its layer is safe to share.
|
|
481
|
+
const github = yield* GitHub.GitHubApp.pipe(
|
|
482
|
+
Effect.provide(GitHub.GitHubAppLive),
|
|
483
|
+
);
|
|
484
|
+
const services = Layer.mergeAll(
|
|
485
|
+
Cloudflare.R2.ReadWriteBucketBinding,
|
|
486
|
+
Cloudflare.KV.ReadWriteNamespaceBinding,
|
|
487
|
+
SQL.D1Layer(d1),
|
|
488
|
+
Layer.succeed(GitHub.GitHubApp, github),
|
|
489
|
+
Layer.succeed(RegistryConfig, config),
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// Recently resolved runs are reused so the uploads that follow a publish
|
|
493
|
+
// do not repeat the lookup. Failures are not kept.
|
|
494
|
+
const runs = KVCache.make(
|
|
495
|
+
(ref: RunRef) => `run:${ref.repo}#${ref.runId}:${ref.attempt}`,
|
|
496
|
+
Run,
|
|
497
|
+
(ref) => Effect.map(lookupRun(ref), (value) => ({ value, ttl: RUN_CACHE })),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
yield* Cloudflare.Workers.cron(config.cron, () =>
|
|
501
|
+
sweep.pipe(Effect.provide(services)),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const registry = HttpApiBuilder.group(PkgApi, "Registry", (handlers) =>
|
|
505
|
+
handlers
|
|
506
|
+
.handle("publish", ({ payload }) =>
|
|
507
|
+
Effect.flatMap(runs(payload.run), (run) =>
|
|
508
|
+
publish(run, payload.manifest),
|
|
509
|
+
).pipe(Effect.catchTag(storageFailures, (e) => Effect.die(e))),
|
|
510
|
+
)
|
|
511
|
+
// Raw so the body streams into R2 instead of being buffered.
|
|
512
|
+
.handleRaw("uploadTarball", ({ query, params, request }) =>
|
|
513
|
+
Effect.andThen(
|
|
514
|
+
runs(query),
|
|
515
|
+
uploadTarball(request, params.name, params.sha256),
|
|
516
|
+
).pipe(Effect.catchTag("R2Error", (e) => Effect.die(e))),
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
const healthGroup = HttpApiBuilder.group(PkgApi, "Health", (handlers) =>
|
|
521
|
+
handlers.handle("health", () => Effect.succeed({ ok: true })),
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Unlike `HttpApiBuilder.group`, the router defers a route's requirements
|
|
525
|
+
// to request time rather than the layer build, so the services are
|
|
526
|
+
// provided on the route effect itself.
|
|
527
|
+
const installRoutes = HttpRouter.use((router) =>
|
|
528
|
+
Effect.all([
|
|
529
|
+
router.add("GET", "/", banner),
|
|
530
|
+
router.add(
|
|
531
|
+
"*",
|
|
532
|
+
"/*",
|
|
533
|
+
install.pipe(
|
|
534
|
+
Effect.catchTag(storageFailures, (e) => Effect.die(e)),
|
|
535
|
+
Effect.provide(services),
|
|
536
|
+
),
|
|
537
|
+
),
|
|
538
|
+
]),
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
fetch: Layer.mergeAll(HttpApiBuilder.layer(PkgApi), installRoutes).pipe(
|
|
543
|
+
Layer.provide([registry, healthGroup]),
|
|
544
|
+
Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]),
|
|
545
|
+
Layer.provide(services),
|
|
546
|
+
HttpRouter.toHttpEffect,
|
|
547
|
+
),
|
|
548
|
+
};
|
|
549
|
+
}).pipe(Effect.provide(bindings));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as Cloudflare from "alchemy/Cloudflare";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
import { Cache } from "./Bindings.ts";
|
|
6
|
+
|
|
7
|
+
/** KV's floor for both `expirationTtl` and `cacheTtl`. */
|
|
8
|
+
const MIN_TTL = Duration.minutes(1);
|
|
9
|
+
|
|
10
|
+
const seconds = (duration: Duration.Duration) =>
|
|
11
|
+
Math.floor(Duration.toSeconds(duration));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A lookup-through cache on the registry's KV namespace, shared by every
|
|
15
|
+
* isolate of the Worker.
|
|
16
|
+
*
|
|
17
|
+
* Concurrent misses each run the lookup and each write; the last write wins.
|
|
18
|
+
* That is deliberate: a cache that shares an in-flight lookup, like Effect's
|
|
19
|
+
* `Cache`, has later callers join the first caller's fiber, and workerd drops
|
|
20
|
+
* a fiber resumed from another request's context once that request
|
|
21
|
+
* completes, which surfaces as the joining request hanging.
|
|
22
|
+
*
|
|
23
|
+
* Reads set `cacheTtl`, so a value can still be served from the colo's edge
|
|
24
|
+
* cache for up to a minute after it expired; a lookup's `ttl` must leave
|
|
25
|
+
* that margin. Values with less than a minute left are not stored. KV
|
|
26
|
+
* failures are logged and fall through to the lookup.
|
|
27
|
+
*/
|
|
28
|
+
export const make = <K, A, I, E, R>(
|
|
29
|
+
key: (input: K) => string,
|
|
30
|
+
value: Schema.Codec<A, I>,
|
|
31
|
+
lookup: (
|
|
32
|
+
input: K,
|
|
33
|
+
) => Effect.Effect<{ value: A; ttl: Duration.Duration }, E, R>,
|
|
34
|
+
) => {
|
|
35
|
+
const json = Schema.fromJsonString(value);
|
|
36
|
+
const decode = Schema.decodeUnknownEffect(json);
|
|
37
|
+
const encode = Schema.encodeEffect(json);
|
|
38
|
+
return (input: K) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const kv = yield* Cloudflare.KV.ReadWriteNamespace(Cache);
|
|
41
|
+
const id = key(input);
|
|
42
|
+
const hit = yield* kv.get(id, { cacheTtl: seconds(MIN_TTL) }).pipe(
|
|
43
|
+
Effect.flatMap((text) =>
|
|
44
|
+
text === null ? Effect.succeed(undefined) : decode(text),
|
|
45
|
+
),
|
|
46
|
+
Effect.catch((e) =>
|
|
47
|
+
Effect.logWarning(`cache read of ${id} failed: ${e}`).pipe(
|
|
48
|
+
Effect.as(undefined),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
if (hit !== undefined) {
|
|
53
|
+
return hit;
|
|
54
|
+
}
|
|
55
|
+
const fresh = yield* lookup(input);
|
|
56
|
+
if (Duration.isGreaterThanOrEqualTo(fresh.ttl, MIN_TTL)) {
|
|
57
|
+
yield* encode(fresh.value).pipe(
|
|
58
|
+
Effect.flatMap((text) =>
|
|
59
|
+
kv.put(id, text, { expirationTtl: seconds(fresh.ttl) }),
|
|
60
|
+
),
|
|
61
|
+
Effect.catch((e) =>
|
|
62
|
+
Effect.logWarning(`cache write of ${id} failed: ${e}`),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return fresh.value;
|
|
67
|
+
});
|
|
68
|
+
};
|