1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Namespace from "alchemy/Namespace";
3import type * as ByteSize from "effect/ByteSize";
4import * as Config from "effect/Config";
5import type * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import {
10 APP_ID_ENV,
11 DEFAULT_API_URL,
12 DEFAULT_CRON,
13 DEFAULT_TTL,
14 PRIVATE_KEY_ENV,
15 RegistryConfig,
16 SETTINGS_ENV,
17 Settings,
18 type GitHubCredentials,
19 type WorkerOverrides,
20} from "./Registry/Bindings.ts";
21import { handler } from "./Registry/Handler.ts";
22
23export {
24 Bucket,
25 Cache,
26 Database,
27 Policy,
28 RegistryConfig,
29 Settings,
30} from "./Registry/Bindings.ts";
31
32export interface RegistryOptions {
33 /**
34 * Worker props forwarded verbatim: `domain`, `name`, `compatibility`,
35 * `observability`, `access`, and so on. `main`, `env`, and `crons` are
36 * owned by the registry.
37 */
38 readonly worker?: WorkerOverrides;
39 /** GitHub App credentials, read at deploy time and bound to the Worker. */
40 readonly github: GitHubCredentials & {
41 /** @default "https://api.github.com" */
42 readonly apiUrl?: string;
43 };
44 readonly policy: {
45 /** Repositories allowed to publish, as `owner/name`. */
46 readonly repos: ReadonlyArray<string>;
47 /** @default Duration.weeks(1) */
48 readonly ttl?: Duration.Duration;
49 /** Upper bound on a single tarball. Absent means unlimited. */
50 readonly maxPackageSize?: ByteSize.ByteSize;
51 };
52 /**
53 * Hostname to package scope. Requests on an aliased host resolve an
54 * unscoped name under that scope, so `pkg.distilled.cloud/core/<sha>`
55 * serves `@distilled.cloud/core`.
56 */
57 readonly aliases?: Readonly<Record<string, string>>;
58 /**
59 * Cron expression for the expiry sweep.
60 * @default "0 * * * *"
61 */
62 readonly cron?: string;
63}
64
65/**
66 * The registry Worker. Its props are an Effect so the same declaration
67 * serves both sides of the bundle boundary: at plan time
68 * {@link RegistryConfig} is the caller's options, inside the isolate it is
69 * rebuilt from the Worker's bindings.
70 */
71export class Registry extends Cloudflare.Worker<Registry, {}>()("Worker") {}
72
73const RegistryLive = Registry.make(
74 Effect.gen(function* () {
75 const config = yield* RegistryConfig;
76 return {
77 ...config.worker,
78 main: import.meta.url,
79 env: {
80 [SETTINGS_ENV]: yield* Schema.encodeEffect(Settings)({
81 policy: config.policy,
82 aliases: config.aliases,
83 cron: config.cron,
84 github: { apiUrl: config.github.apiUrl },
85 }).pipe(Effect.orDie),
86 [APP_ID_ENV]: config.github.appId,
87 [PRIVATE_KEY_ENV]: config.github.privateKey,
88 },
89 };
90 }),
91 handler,
92);
93
94/**
95 * Runtime side of {@link RegistryConfig}: decode the settings binding and
96 * point the credentials at the bindings `RegistryLive` declared.
97 */
98const RegistryConfigFromEnv = Layer.effect(
99 RegistryConfig,
100 Effect.gen(function* () {
101 const env = yield* Cloudflare.Workers.WorkerEnvironment;
102 const settings = yield* Schema.decodeUnknownEffect(Settings)(
103 env[SETTINGS_ENV],
104 ).pipe(Effect.orDie);
105 return {
106 ...settings,
107 worker: {},
108 github: {
109 ...settings.github,
110 appId: Config.String(APP_ID_ENV),
111 privateKey: Config.Redacted(PRIVATE_KEY_ENV),
112 },
113 };
114 }),
115);
116
117/**
118 * The bundle entry. `RegistryLive` names this file as `main`, so the
119 * generated Worker entry imports this default export and builds it once per
120 * isolate. User code never imports it; use {@link PkgRegistry}.
121 */
122export default RegistryLive.pipe(Layer.provide(RegistryConfigFromEnv));
123
124/**
125 * A preview package registry: a Worker serving install URLs and the publish
126 * API, an R2 bucket of content-addressed tarballs, a D1 index of tags, and a
127 * KV namespace caching what the Worker learns from GitHub. They are declared
128 * under `id` as `<id>/Worker`, `<id>/Bucket`, `<id>/Database`, and
129 * `<id>/Cache`.
130 *
131 * ### Deploying a registry
132 * **Example:** Stack
133 * ```typescript
134 * // stacks/pkg.ts
135 * import { PkgRegistry } from "@alchemy.run/pkg/Registry";
136 * import * as Alchemy from "alchemy";
137 * import * as Cloudflare from "alchemy/Cloudflare";
138 * import * as ByteSize from "effect/ByteSize";
139 * import * as Config from "effect/Config";
140 * import * as Duration from "effect/Duration";
141 * import * as Effect from "effect/Effect";
142 *
143 * export default Alchemy.Stack(
144 * "Pkg",
145 * { providers: Cloudflare.providers(), state: Cloudflare.state() },
146 * Effect.gen(function* () {
147 * const registry = yield* PkgRegistry("Pkg", {
148 * worker: { domain: "pkg.alchemy.run" },
149 * github: {
150 * appId: Config.String("GH_APP_ID"),
151 * privateKey: Config.Redacted("GH_APP_PRIVATE_KEY"),
152 * },
153 * policy: {
154 * repos: ["alchemy-run/alchemy", "alchemy-run/distilled"],
155 * ttl: Duration.weeks(1),
156 * maxPackageSize: ByteSize.megabytes(100),
157 * },
158 * });
159 * return { url: registry.url.as<string>() };
160 * }),
161 * );
162 * ```
163 *
164 * @resource
165 * @product Workers
166 * @category Workers & Compute
167 */
168export const PkgRegistry = <const Id extends string>(
169 id: Id,
170 options: RegistryOptions,
171) =>
172 Registry.pipe(
173 Effect.provide(
174 RegistryLive.pipe(
175 Layer.provide(
176 Layer.succeed(RegistryConfig, {
177 worker: options.worker ?? {},
178 policy: {
179 repos: options.policy.repos,
180 ttl: options.policy.ttl ?? DEFAULT_TTL,
181 ...(options.policy.maxPackageSize !== undefined
182 ? { maxPackageSize: options.policy.maxPackageSize }
183 : {}),
184 },
185 aliases: options.aliases ?? {},
186 cron: options.cron ?? DEFAULT_CRON,
187 github: {
188 apiUrl: options.github.apiUrl ?? DEFAULT_API_URL,
189 appId: options.github.appId,
190 privateKey: options.github.privateKey,
191 },
192 }),
193 ),
194 ),
195 ),
196 Namespace.push(id),
197 );