@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.
Files changed (70) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +117 -0
  4. package/dist/Manifest.d.ts +107 -0
  5. package/dist/Manifest.d.ts.map +1 -0
  6. package/dist/Manifest.js +58 -0
  7. package/dist/Manifest.js.map +1 -0
  8. package/dist/Protocol.d.ts +186 -0
  9. package/dist/Protocol.d.ts.map +1 -0
  10. package/dist/Protocol.js +115 -0
  11. package/dist/Protocol.js.map +1 -0
  12. package/dist/Registry/Bindings.d.ts +134 -0
  13. package/dist/Registry/Bindings.d.ts.map +1 -0
  14. package/dist/Registry/Bindings.js +92 -0
  15. package/dist/Registry/Bindings.js.map +1 -0
  16. package/dist/Registry/GitHub.d.ts +96 -0
  17. package/dist/Registry/GitHub.d.ts.map +1 -0
  18. package/dist/Registry/GitHub.js +226 -0
  19. package/dist/Registry/GitHub.js.map +1 -0
  20. package/dist/Registry/Handler.d.ts +53 -0
  21. package/dist/Registry/Handler.d.ts.map +1 -0
  22. package/dist/Registry/Handler.js +375 -0
  23. package/dist/Registry/Handler.js.map +1 -0
  24. package/dist/Registry/KVCache.d.ts +24 -0
  25. package/dist/Registry/KVCache.d.ts.map +1 -0
  26. package/dist/Registry/KVCache.js +42 -0
  27. package/dist/Registry/KVCache.js.map +1 -0
  28. package/dist/Registry/Sweep.d.ts +12 -0
  29. package/dist/Registry/Sweep.d.ts.map +1 -0
  30. package/dist/Registry/Sweep.js +69 -0
  31. package/dist/Registry/Sweep.js.map +1 -0
  32. package/dist/Registry/Tags.d.ts +49 -0
  33. package/dist/Registry/Tags.d.ts.map +1 -0
  34. package/dist/Registry/Tags.js +80 -0
  35. package/dist/Registry/Tags.js.map +1 -0
  36. package/dist/Registry.d.ts +378 -0
  37. package/dist/Registry.d.ts.map +1 -0
  38. package/dist/Registry.js +119 -0
  39. package/dist/Registry.js.map +1 -0
  40. package/dist/cli/bin.d.ts +18 -0
  41. package/dist/cli/bin.d.ts.map +1 -0
  42. package/dist/cli/bin.js +33 -0
  43. package/dist/cli/bin.js.map +1 -0
  44. package/dist/cli/pack.d.ts +169 -0
  45. package/dist/cli/pack.d.ts.map +1 -0
  46. package/dist/cli/pack.js +447 -0
  47. package/dist/cli/pack.js.map +1 -0
  48. package/dist/cli/publish.d.ts +33 -0
  49. package/dist/cli/publish.d.ts.map +1 -0
  50. package/dist/cli/publish.js +76 -0
  51. package/dist/cli/publish.js.map +1 -0
  52. package/dist/index.d.ts +3 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +6 -0
  55. package/dist/index.js.map +1 -0
  56. package/migrations/0001_init.sql +19 -0
  57. package/package.json +89 -1
  58. package/src/Manifest.ts +76 -0
  59. package/src/Protocol.ts +163 -0
  60. package/src/Registry/Bindings.ts +134 -0
  61. package/src/Registry/GitHub.ts +365 -0
  62. package/src/Registry/Handler.ts +549 -0
  63. package/src/Registry/KVCache.ts +68 -0
  64. package/src/Registry/Sweep.ts +117 -0
  65. package/src/Registry/Tags.ts +117 -0
  66. package/src/Registry.ts +197 -0
  67. package/src/cli/bin.ts +101 -0
  68. package/src/cli/pack.ts +603 -0
  69. package/src/cli/publish.ts +129 -0
  70. 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
+ };