@alchemy.run/boat 0.0.0 → 2.0.0-beta.87

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 (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +66 -4
  3. package/lib/AiProxy.d.ts +89 -0
  4. package/lib/AiProxy.d.ts.map +1 -0
  5. package/lib/AiProxy.js +125 -0
  6. package/lib/AiProxy.js.map +1 -0
  7. package/lib/ApiKey.d.ts +105 -0
  8. package/lib/ApiKey.d.ts.map +1 -0
  9. package/lib/ApiKey.js +134 -0
  10. package/lib/ApiKey.js.map +1 -0
  11. package/lib/AuthProvider.d.ts +71 -0
  12. package/lib/AuthProvider.d.ts.map +1 -0
  13. package/lib/AuthProvider.js +322 -0
  14. package/lib/AuthProvider.js.map +1 -0
  15. package/lib/BoatBindings.d.ts +52 -0
  16. package/lib/BoatBindings.d.ts.map +1 -0
  17. package/lib/BoatBindings.js +104 -0
  18. package/lib/BoatBindings.js.map +1 -0
  19. package/lib/Bootstrap.d.ts +8 -0
  20. package/lib/Bootstrap.d.ts.map +1 -0
  21. package/lib/Bootstrap.js +28 -0
  22. package/lib/Bootstrap.js.map +1 -0
  23. package/lib/ConnectSandbox.d.ts +56 -0
  24. package/lib/ConnectSandbox.d.ts.map +1 -0
  25. package/lib/ConnectSandbox.js +3 -0
  26. package/lib/ConnectSandbox.js.map +1 -0
  27. package/lib/ConnectSandboxHttp.d.ts +21 -0
  28. package/lib/ConnectSandboxHttp.d.ts.map +1 -0
  29. package/lib/ConnectSandboxHttp.js +46 -0
  30. package/lib/ConnectSandboxHttp.js.map +1 -0
  31. package/lib/Contributions.d.ts +7 -0
  32. package/lib/Contributions.d.ts.map +1 -0
  33. package/lib/Contributions.js +6 -0
  34. package/lib/Contributions.js.map +1 -0
  35. package/lib/CreateSandbox.d.ts +134 -0
  36. package/lib/CreateSandbox.d.ts.map +1 -0
  37. package/lib/CreateSandbox.js +3 -0
  38. package/lib/CreateSandbox.js.map +1 -0
  39. package/lib/CreateSandboxHttp.d.ts +21 -0
  40. package/lib/CreateSandboxHttp.d.ts.map +1 -0
  41. package/lib/CreateSandboxHttp.js +93 -0
  42. package/lib/CreateSandboxHttp.js.map +1 -0
  43. package/lib/CredentialProfile.d.ts +157 -0
  44. package/lib/CredentialProfile.d.ts.map +1 -0
  45. package/lib/CredentialProfile.js +245 -0
  46. package/lib/CredentialProfile.js.map +1 -0
  47. package/lib/Credentials.d.ts +15 -0
  48. package/lib/Credentials.d.ts.map +1 -0
  49. package/lib/Credentials.js +23 -0
  50. package/lib/Credentials.js.map +1 -0
  51. package/lib/Environment.d.ts +219 -0
  52. package/lib/Environment.d.ts.map +1 -0
  53. package/lib/Environment.js +273 -0
  54. package/lib/Environment.js.map +1 -0
  55. package/lib/Errors.d.ts +50 -0
  56. package/lib/Errors.d.ts.map +1 -0
  57. package/lib/Errors.js +17 -0
  58. package/lib/Errors.js.map +1 -0
  59. package/lib/Providers.d.ts +33 -0
  60. package/lib/Providers.d.ts.map +1 -0
  61. package/lib/Providers.js +47 -0
  62. package/lib/Providers.js.map +1 -0
  63. package/lib/Sandbox.d.ts +205 -0
  64. package/lib/Sandbox.d.ts.map +1 -0
  65. package/lib/Sandbox.js +368 -0
  66. package/lib/Sandbox.js.map +1 -0
  67. package/lib/SandboxClient.d.ts +159 -0
  68. package/lib/SandboxClient.d.ts.map +1 -0
  69. package/lib/SandboxClient.js +1 -0
  70. package/lib/SandboxClient.js.map +1 -0
  71. package/lib/SandboxEvents.d.ts +111 -0
  72. package/lib/SandboxEvents.d.ts.map +1 -0
  73. package/lib/SandboxEvents.js +60 -0
  74. package/lib/SandboxEvents.js.map +1 -0
  75. package/lib/SandboxEventsLive.d.ts +13 -0
  76. package/lib/SandboxEventsLive.d.ts.map +1 -0
  77. package/lib/SandboxEventsLive.js +63 -0
  78. package/lib/SandboxEventsLive.js.map +1 -0
  79. package/lib/Snapshot.d.ts +123 -0
  80. package/lib/Snapshot.d.ts.map +1 -0
  81. package/lib/Snapshot.js +173 -0
  82. package/lib/Snapshot.js.map +1 -0
  83. package/lib/Webhook.d.ts +56 -0
  84. package/lib/Webhook.d.ts.map +1 -0
  85. package/lib/Webhook.js +98 -0
  86. package/lib/Webhook.js.map +1 -0
  87. package/lib/index.d.ts +19 -0
  88. package/lib/index.d.ts.map +1 -0
  89. package/lib/index.js +19 -0
  90. package/lib/index.js.map +1 -0
  91. package/lib/internal/Declared.d.ts +34 -0
  92. package/lib/internal/Declared.d.ts.map +1 -0
  93. package/lib/internal/Declared.js +38 -0
  94. package/lib/internal/Declared.js.map +1 -0
  95. package/lib/internal/Org.d.ts +22 -0
  96. package/lib/internal/Org.d.ts.map +1 -0
  97. package/lib/internal/Org.js +23 -0
  98. package/lib/internal/Org.js.map +1 -0
  99. package/lib/internal/Ownership.d.ts +29 -0
  100. package/lib/internal/Ownership.d.ts.map +1 -0
  101. package/lib/internal/Ownership.js +50 -0
  102. package/lib/internal/Ownership.js.map +1 -0
  103. package/lib/internal/Program.d.ts +69 -0
  104. package/lib/internal/Program.d.ts.map +1 -0
  105. package/lib/internal/Program.js +178 -0
  106. package/lib/internal/Program.js.map +1 -0
  107. package/lib/internal/Ref.d.ts +14 -0
  108. package/lib/internal/Ref.d.ts.map +1 -0
  109. package/lib/internal/Ref.js +7 -0
  110. package/lib/internal/Ref.js.map +1 -0
  111. package/lib/internal/SandboxHandle.d.ts +17 -0
  112. package/lib/internal/SandboxHandle.d.ts.map +1 -0
  113. package/lib/internal/SandboxHandle.js +238 -0
  114. package/lib/internal/SandboxHandle.js.map +1 -0
  115. package/lib/internal/SavedSnapshot.d.ts +10 -0
  116. package/lib/internal/SavedSnapshot.d.ts.map +1 -0
  117. package/lib/internal/SavedSnapshot.js +22 -0
  118. package/lib/internal/SavedSnapshot.js.map +1 -0
  119. package/lib/internal/WebhookDelivery.d.ts +16 -0
  120. package/lib/internal/WebhookDelivery.d.ts.map +1 -0
  121. package/lib/internal/WebhookDelivery.js +109 -0
  122. package/lib/internal/WebhookDelivery.js.map +1 -0
  123. package/package.json +67 -3
  124. package/src/AiProxy.ts +171 -0
  125. package/src/ApiKey.ts +250 -0
  126. package/src/AuthProvider.ts +472 -0
  127. package/src/BoatBindings.ts +136 -0
  128. package/src/Bootstrap.ts +55 -0
  129. package/src/ConnectSandbox.ts +60 -0
  130. package/src/ConnectSandboxHttp.ts +59 -0
  131. package/src/Contributions.ts +9 -0
  132. package/src/CreateSandbox.ts +143 -0
  133. package/src/CreateSandboxHttp.ts +115 -0
  134. package/src/CredentialProfile.ts +350 -0
  135. package/src/Credentials.ts +33 -0
  136. package/src/Environment.ts +416 -0
  137. package/src/Errors.ts +34 -0
  138. package/src/Providers.ts +71 -0
  139. package/src/Sandbox.ts +647 -0
  140. package/src/SandboxClient.ts +200 -0
  141. package/src/SandboxEvents.ts +125 -0
  142. package/src/SandboxEventsLive.ts +88 -0
  143. package/src/Snapshot.ts +264 -0
  144. package/src/Webhook.ts +163 -0
  145. package/src/globals.d.ts +2 -0
  146. package/src/index.ts +18 -0
  147. package/src/internal/Declared.ts +87 -0
  148. package/src/internal/Org.ts +31 -0
  149. package/src/internal/Ownership.ts +75 -0
  150. package/src/internal/Program.ts +250 -0
  151. package/src/internal/Ref.ts +15 -0
  152. package/src/internal/SandboxHandle.ts +407 -0
  153. package/src/internal/SavedSnapshot.ts +34 -0
  154. package/src/internal/WebhookDelivery.ts +179 -0
package/src/Sandbox.ts ADDED
@@ -0,0 +1,647 @@
1
+ import * as boat from "@distilled.cloud/boat-dev/boat";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import type * as Bundle from "alchemy/Bundle/Bundle";
4
+ import { isResolved } from "alchemy/Diff";
5
+ import { Platform, type Main, type PlatformProps } from "alchemy/Platform";
6
+ import * as Provider from "alchemy/Provider";
7
+ import { Resource, type ResourceBinding } from "alchemy/Resource";
8
+ import {
9
+ createHostRuntimeContext,
10
+ type HostRuntimeContext,
11
+ type ServerHost,
12
+ } from "alchemy/Runtime/Process";
13
+ import { Stack } from "alchemy/Stack";
14
+ import * as Duration from "effect/Duration";
15
+ import * as Effect from "effect/Effect";
16
+ import * as Redacted from "effect/Redacted";
17
+ import * as Stream from "effect/Stream";
18
+ import { isDeclaration } from "./internal/Declared.ts";
19
+ import {
20
+ boatName,
21
+ hasAnyAlchemyLabel,
22
+ hasOwnershipLabels,
23
+ ownershipLabels,
24
+ userLabels,
25
+ } from "./internal/Ownership.ts";
26
+ import {
27
+ bundleProgram,
28
+ DEFAULT_PORT,
29
+ deployProgram,
30
+ isProgramRunning,
31
+ type ProgramState,
32
+ } from "./internal/Program.ts";
33
+ import { resolved, type Ref } from "./internal/Ref.ts";
34
+ import type { Providers } from "./Providers.ts";
35
+ import type { SnapshotBase } from "./Snapshot.ts";
36
+
37
+ /**
38
+ * Machine size. `small` runs at half the default rate and `large` at
39
+ * twice; `xlarge` needs the $100/month plan or higher.
40
+ */
41
+ export type SandboxType = "small" | "default" | "large" | "xlarge";
42
+
43
+ /** Where a sandbox is in its life. `archived` is stopped, with its disk saved. */
44
+ export type SandboxState =
45
+ | "init"
46
+ | "provisioning"
47
+ | "provisioned"
48
+ | "cloning"
49
+ | "ready"
50
+ | "idle"
51
+ | "running"
52
+ | "archiving"
53
+ | "archived"
54
+ | "error"
55
+ | "cancelled";
56
+
57
+ /** How the setup script went: `pending`, `running`, `done` or `failed`. */
58
+ export type SandboxSetupStatus = "pending" | "running" | "done" | "failed";
59
+
60
+ /** An environment, by name. Accepts a `Boat.Environment` or `{ name }`. */
61
+ export interface SandboxEnvironmentRef {
62
+ name: string;
63
+ }
64
+
65
+ /** What bindings attach to a sandbox that runs a program: its process's environment. */
66
+ export interface SandboxBinding {
67
+ env?: Record<string, any>;
68
+ }
69
+
70
+ export interface SandboxProps extends PlatformProps {
71
+ /**
72
+ * Display name. If omitted, a unique name is generated from the stack,
73
+ * stage and logical ID. Renamed in place.
74
+ */
75
+ name?: string;
76
+ /**
77
+ * Machine size. Changing it replaces the sandbox.
78
+ * @default "default"
79
+ */
80
+ type?: SandboxType;
81
+ /**
82
+ * How long the sandbox runs before Boat stops it, saving its disk;
83
+ * `false` never stops it. Updated in place: the next stop is then
84
+ * `autoStop` from the deploy.
85
+ * @default "1 hour", or `false` for a sandbox with `main`
86
+ */
87
+ autoStop?: Duration.Input | false;
88
+ /**
89
+ * Environment variables. On a sandbox without `main` they are the
90
+ * sandbox's, on top of its environment's, and changing them replaces it.
91
+ * On a sandbox with `main` they are the program's, and changing them
92
+ * restarts it.
93
+ */
94
+ env?: Record<string, string | Redacted.Redacted<string>>;
95
+ /**
96
+ * The `Boat.Environment` to attach (or `{ name }` of one): its
97
+ * variables, secret files and credential settings. Omit for the account's
98
+ * default environment (`base`). Changing it replaces the sandbox.
99
+ */
100
+ environment?: Ref<SandboxEnvironmentRef>;
101
+ /**
102
+ * Attach none of the account's secrets (no environment variables, secret
103
+ * files or credentials), and confine the sandbox so it can't act on the
104
+ * account. For sandboxes you give to your own users. Changing it replaces
105
+ * the sandbox.
106
+ * @default false
107
+ */
108
+ noEnv?: boolean;
109
+ /**
110
+ * `false` never snapshots the sandbox: stopping it erases its disk, and it
111
+ * can't be resumed, forked or saved. Changing it replaces the sandbox.
112
+ * @default true
113
+ */
114
+ snapshots?: boolean;
115
+ /**
116
+ * `false` keeps no command output, process list or metrics for the
117
+ * sandbox. Changing it replaces the sandbox.
118
+ * @default true
119
+ */
120
+ monitoring?: boolean;
121
+ /**
122
+ * A shell script run once, in the background, after the sandbox starts.
123
+ * Its outcome is `setupStatus`. Changing it replaces the sandbox.
124
+ */
125
+ setupScript?: string;
126
+ /**
127
+ * The `Boat.Snapshot` to start from (or `{ name }` of one saved
128
+ * elsewhere, or a share code `bsh_...`). Changing or rebuilding it
129
+ * replaces the sandbox, so it starts from the new disk.
130
+ */
131
+ snapshot?: Ref<SnapshotBase>;
132
+ /**
133
+ * Your own labels, at most 17 (Alchemy adds three). Boat sets labels
134
+ * only at create, so changing them replaces the sandbox.
135
+ */
136
+ labels?: Record<string, string>;
137
+ /**
138
+ * The sandbox's subdomain under `on.boat.dev`. Port `p` answers at
139
+ * `https://<subdomain>-<p>.on.boat.dev`. Renamed in place.
140
+ */
141
+ subdomain?: string;
142
+ /**
143
+ * Module entrypoint of a program to run in the sandbox, typically
144
+ * `import.meta.url` in the class form. Alchemy bundles it, uploads it
145
+ * into the sandbox and runs it with Node 24; a code change uploads it
146
+ * again and restarts the process, without replacing the sandbox.
147
+ */
148
+ main?: string;
149
+ /**
150
+ * Port the program listens on. Written to `PORT`; Boat serves it at
151
+ * `url`.
152
+ * @default 3000
153
+ */
154
+ port?: number;
155
+ /**
156
+ * Serve the program's port to anyone. Otherwise requests need `urlToken`
157
+ * (as the `_token` query parameter, or the `_port_auth` cookie).
158
+ * Updated in place.
159
+ * @default false
160
+ */
161
+ public?: boolean;
162
+ /**
163
+ * Named export to load from `main`.
164
+ * @default "default"
165
+ */
166
+ handler?: string;
167
+ /** Bundler configuration for `main`. */
168
+ build?: Bundle.BundleConfig;
169
+ /**
170
+ * Who in the organization that pays for the sandbox can use it:
171
+ * `private`, only you; `shared`, every member. Omitted, the organization's
172
+ * default for new sandboxes applies. Updated in place. Sharing a sandbox
173
+ * that holds your logins (GitHub, Claude and Codex logins, environment
174
+ * secrets) wipes them at its next start and never pushes them again, so
175
+ * share one with `noEnv` or an environment that passes nothing. A
176
+ * personal sandbox is always private, and Boat refuses `shared` for one.
177
+ */
178
+ visibility?: "shared" | "private";
179
+ }
180
+
181
+ export interface SandboxAttributes {
182
+ /** Boat's sandbox id (`bx_…`). */
183
+ sandboxId: string;
184
+ name: string;
185
+ /** `ready`, `running`, `idle`, `archived` (stopped), … */
186
+ state: SandboxState;
187
+ type: SandboxType | undefined;
188
+ /** vCPUs and RAM (GB) the machine size guarantees. */
189
+ vcpu: number | undefined;
190
+ memoryGB: number | undefined;
191
+ /**
192
+ * The subdomain its ports are served under: port `p` answers at
193
+ * `https://<subdomain>-<p>.on.boat.dev`.
194
+ */
195
+ subdomain: string | undefined;
196
+ /** The machine's URL. */
197
+ machineUrl: string | undefined;
198
+ /** The environment the sandbox runs, and its version. */
199
+ environment: string | undefined;
200
+ environmentVersion: number | undefined;
201
+ /** Seconds it runs before stopping, or `undefined` when it never stops. */
202
+ ttlSeconds: number | undefined;
203
+ /** Your labels, without Alchemy's. */
204
+ labels: Record<string, string>;
205
+ /** The setup script's outcome: `pending`, `running`, `done` or `failed`. */
206
+ setupStatus: SandboxSetupStatus | undefined;
207
+ /** The program's URL, for a sandbox with `main`. */
208
+ url: string | undefined;
209
+ /**
210
+ * The token a request to `url` needs unless the sandbox is `public`: the
211
+ * `_token` query parameter, or the `_port_auth` cookie.
212
+ */
213
+ urlToken: Redacted.Redacted<string> | undefined;
214
+ /** The program the sandbox runs, for a sandbox with `main`. */
215
+ program: ProgramState | undefined;
216
+ /**
217
+ * The organization that pays for it, fixed when it was created;
218
+ * `undefined` when you do.
219
+ */
220
+ org: { id: string; name: string } | undefined;
221
+ /** `shared` or `private` for an organization's sandbox; `undefined` for yours. */
222
+ visibility: "shared" | "private" | undefined;
223
+ }
224
+
225
+ export type Sandbox = Resource<
226
+ "Boat.Sandbox",
227
+ SandboxProps,
228
+ SandboxAttributes,
229
+ SandboxBinding,
230
+ Providers,
231
+ { sandboxId: string }
232
+ >;
233
+
234
+ export type SandboxServices = ServerHost;
235
+
236
+ /** A program's shape: a long-running server returning `{ fetch }`, or `host.run` loops. */
237
+ export type SandboxShape = Main<SandboxServices>;
238
+
239
+ export type SandboxRuntimeContext = HostRuntimeContext;
240
+
241
+ /**
242
+ * A Boat sandbox the stack owns: a Linux machine with Docker, passwordless
243
+ * `sudo` and a desktop, that stops itself after `autoStop` and keeps its
244
+ * disk until it is resumed.
245
+ *
246
+ * Use it for a workspace that outlives requests (an agent's home, a dev
247
+ * box, a preview environment). Bindings create short-lived sandboxes at
248
+ * run time with `Boat.CreateSandbox`.
249
+ *
250
+ * Alchemy labels the sandbox with its stack, stage and logical ID
251
+ * (`alchemy.stack`, `alchemy.stage`, `alchemy.id`) to recognise the
252
+ * sandboxes it owns.
253
+ *
254
+ * The sandbox bills the stack's organization (`Boat.providers({ org })`,
255
+ * or the profile's), else your active wallet. Boat fixes the wallet at
256
+ * create, so changing the stack's `org` later leaves the sandbox where it
257
+ * is; `org` says which.
258
+ *
259
+ * :::caution
260
+ * Replacing the sandbox (a new `type`, `env`, `environment`, `noEnv`,
261
+ * `snapshots`, `monitoring`, `setupScript`, `labels`, or a new or rebuilt
262
+ * `snapshot`) creates a fresh sandbox and deletes the old one with its disk.
263
+ * :::
264
+ *
265
+ * ### Creating a Sandbox
266
+ * **Example:** A dev box that stops after an idle hour
267
+ * ```typescript
268
+ * import * as Boat from "@alchemy.run/boat";
269
+ *
270
+ * export const DevBox = Boat.Sandbox("DevBox", {
271
+ * type: "large",
272
+ * autoStop: "1 hour",
273
+ * setupScript: "git clone https://github.com/alchemy-run/alchemy ~/alchemy",
274
+ * });
275
+ * ```
276
+ *
277
+ * **Example:** A sandbox with none of the account's secrets
278
+ * ```typescript
279
+ * export const Untrusted = Boat.Sandbox("Untrusted", {
280
+ * noEnv: true,
281
+ * env: { TASK: "lint" },
282
+ * });
283
+ * ```
284
+ *
285
+ * ### Running a Program
286
+ * The class form runs an Effect program in the sandbox: give it `main` and
287
+ * an implementation, and Alchemy bundles the program, uploads it and runs
288
+ * it with Node, served at `url`. A code change uploads the new bundle and
289
+ * restarts the process; the sandbox and its URL stay the same. A sandbox
290
+ * with `main` never stops on its own unless `autoStop` says so; if it
291
+ * stopped anyway, the next deploy resumes it and starts the program.
292
+ *
293
+ * **Example:** An HTTP server
294
+ * ```typescript
295
+ * export default class Api extends Boat.Sandbox<Api>()(
296
+ * "Api",
297
+ * { main: import.meta.url, public: true },
298
+ * Effect.gen(function* () {
299
+ * return {
300
+ * fetch: Effect.gen(function* () {
301
+ * return yield* HttpServerResponse.json({ ok: true });
302
+ * }),
303
+ * };
304
+ * }),
305
+ * ) {}
306
+ * ```
307
+ *
308
+ * ### Starting from a snapshot and an environment
309
+ * **Example:** A sandbox with a project set up and its secrets
310
+ * ```typescript
311
+ * export const Golden = Boat.Snapshot("Golden", { run: ["npm i -g pnpm"] });
312
+ * export const MyEnv = Boat.Environment("MyEnv", {
313
+ * env: { API_TOKEN: Config.Redacted("API_TOKEN") },
314
+ * });
315
+ *
316
+ * export const Box = Boat.Sandbox("Box", {
317
+ * snapshot: Golden,
318
+ * environment: MyEnv,
319
+ * autoStop: "30 minutes",
320
+ * });
321
+ * ```
322
+ *
323
+ * ### Sharing with an organization
324
+ * **Example:** A sandbox every member of the team can use
325
+ * ```typescript
326
+ * // with Boat.providers({ org: "acme" }) on the stack
327
+ * export const TeamBox = Boat.Sandbox("TeamBox", {
328
+ * noEnv: true,
329
+ * visibility: "shared",
330
+ * });
331
+ * ```
332
+ *
333
+ * ### Using it at runtime
334
+ * **Example:** Run a command in it from a Worker
335
+ * ```typescript
336
+ * const devBox = yield* Boat.ConnectSandbox(DevBox);
337
+ * // in fetch:
338
+ * yield* devBox.resume();
339
+ * const result = yield* devBox.exec("git pull && npm test", { cwd: "/home/user/repo" });
340
+ * ```
341
+ *
342
+ * ### Referencing a sandbox the stack doesn't manage
343
+ * **Example:** Look one up by id
344
+ * ```typescript
345
+ * const shared = Boat.Sandbox.lookup({ sandboxId: "bx_abc123" });
346
+ * const box = yield* Boat.ConnectSandbox(shared);
347
+ * ```
348
+ *
349
+ * @resource
350
+ * @product Sandbox
351
+ */
352
+ /** The plain resource behind {@link Sandbox}, which carries `lookup`. */
353
+ const SandboxResource = Resource<Sandbox>("Boat.Sandbox");
354
+
355
+ export const Sandbox: Platform<Sandbox, SandboxServices, SandboxShape, SandboxRuntimeContext> & {
356
+ /** References a sandbox the stack doesn't manage, by id. */
357
+ readonly lookup: ReturnType<typeof Resource<Sandbox>>["lookup"];
358
+ } = Object.assign(
359
+ Platform("Boat.Sandbox", {
360
+ createRuntimeContext: createHostRuntimeContext("Boat.Sandbox"),
361
+ // A declaration passed as a prop (`snapshot: Golden`) is an Effect until
362
+ // yielded. Yield it here so it is registered and the prop resolves to
363
+ // its attributes, as with `yield* Golden` inside Effect.gen.
364
+ transformProps: (_id, props: SandboxProps) =>
365
+ Effect.gen(function* () {
366
+ if (globalThis.__ALCHEMY_RUNTIME__) return props;
367
+ // A lookup (`Boat.Environment.lookup({ name })`) is an Output, which
368
+ // the engine resolves; only a declaration is yielded.
369
+ const ref = <T>(value: T) =>
370
+ isDeclaration(value)
371
+ ? (value as unknown as Effect.Effect<T, never, Providers>)
372
+ : Effect.succeed(value);
373
+ return {
374
+ ...props,
375
+ snapshot: props.snapshot === undefined ? undefined : yield* ref(props.snapshot),
376
+ environment: props.environment === undefined ? undefined : yield* ref(props.environment),
377
+ };
378
+ }),
379
+ }),
380
+ { lookup: (props: { sandboxId: string }) => SandboxResource.lookup(props) },
381
+ );
382
+
383
+ /** A sandbox, or `undefined` when Boat no longer has it. */
384
+ export const observeSandbox = (sandboxId: string) =>
385
+ boat.getSandbox({ sandboxId }).pipe(
386
+ Effect.map((got) =>
387
+ got.sandbox === undefined || got.sandbox.state === "cancelled" ? undefined : got.sandbox,
388
+ ),
389
+ Effect.catchTag("NotFound", () => Effect.undefined),
390
+ );
391
+
392
+ /** Delete a sandbox; deleting one that is gone succeeds. */
393
+ export const deleteSandbox = (sandboxId: string) =>
394
+ boat.deleteSandbox({ sandboxId, xAsciiConfirmDelete: sandboxId }).pipe(
395
+ Effect.asVoid,
396
+ Effect.catchTag("NotFound", () => Effect.void),
397
+ );
398
+
399
+ const plainEnv = (env: SandboxProps["env"]) =>
400
+ env === undefined
401
+ ? undefined
402
+ : Object.fromEntries(
403
+ Object.entries(env).map(([key, value]) => [
404
+ key,
405
+ Redacted.isRedacted(value) ? Redacted.value(value) : value,
406
+ ]),
407
+ );
408
+
409
+ export const toAttributes = (sandbox: boat.Sandbox): SandboxAttributes => ({
410
+ sandboxId: sandbox.id,
411
+ name: sandbox.name,
412
+ state: sandbox.state,
413
+ type: sandbox.type,
414
+ vcpu: sandbox.vcpu,
415
+ memoryGB: sandbox.memoryGB,
416
+ subdomain: sandbox.subdomain ?? undefined,
417
+ machineUrl: sandbox.url ?? undefined,
418
+ environment: sandbox.environment ?? undefined,
419
+ environmentVersion: sandbox.environmentVersion ?? undefined,
420
+ ttlSeconds: sandbox.ttlSeconds ?? undefined,
421
+ labels: userLabels(sandbox.labels),
422
+ setupStatus: sandbox.setupStatus ?? undefined,
423
+ url: undefined,
424
+ urlToken: undefined,
425
+ program: undefined,
426
+ org: sandbox.team ? { id: sandbox.team.id, name: sandbox.team.name } : undefined,
427
+ visibility: sandbox.visibility ?? undefined,
428
+ });
429
+
430
+ /** The bindings still attached (a removed binding arrives as a `delete`). */
431
+ const activeBindings = (bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>) =>
432
+ bindings.filter(
433
+ (binding: ResourceBinding<SandboxBinding> & { action?: string }) => binding.action !== "delete",
434
+ );
435
+
436
+ /** The program's environment from its bindings. */
437
+ const bindingEnv = (bindings: ReadonlyArray<ResourceBinding<SandboxBinding>>) =>
438
+ activeBindings(bindings).reduce<Record<string, any>>(
439
+ (acc, binding) => ({ ...acc, ...binding.data?.env }),
440
+ {},
441
+ );
442
+
443
+ /**
444
+ * Boat's `ttlSeconds` for `autoStop`: `null` never stops. Without
445
+ * `autoStop`, a program sandbox never stops and others keep Boat's default.
446
+ */
447
+ const ttlSecondsOf = (autoStop: Duration.Input | false | undefined, program: boolean) =>
448
+ autoStop === false
449
+ ? null
450
+ : autoStop === undefined
451
+ ? program
452
+ ? null
453
+ : undefined
454
+ : Math.max(1, Math.round(Duration.toMillis(Duration.fromInputUnsafe(autoStop)) / 1000));
455
+
456
+ /** Props whose change means a new sandbox (Boat can't change them on a live one). */
457
+ const replacingProps = (props: SandboxProps) =>
458
+ JSON.stringify({
459
+ type: props.type ?? "default",
460
+ // A program's env is its process's: changing it restarts the program.
461
+ env: props.main === undefined ? (plainEnv(props.env) ?? {}) : undefined,
462
+ program: props.main !== undefined,
463
+ environment: props.environment === undefined ? undefined : resolved(props.environment).name,
464
+ noEnv: props.noEnv ?? false,
465
+ snapshots: props.snapshots ?? true,
466
+ monitoring: props.monitoring ?? true,
467
+ setupScript: props.setupScript,
468
+ snapshot:
469
+ props.snapshot === undefined
470
+ ? undefined
471
+ : [resolved(props.snapshot).name, resolved(props.snapshot).setupHash ?? null],
472
+ labels: props.labels ?? {},
473
+ });
474
+
475
+ export const SandboxProvider = () =>
476
+ Provider.effect(
477
+ Sandbox,
478
+ Effect.gen(function* () {
479
+ const stack = yield* Stack;
480
+ // The bundled program's bootstrap reads these to rebuild the Stack at runtime.
481
+ const alchemyEnv = {
482
+ ALCHEMY_STACK_NAME: stack.name,
483
+ ALCHEMY_STAGE: stack.stage,
484
+ ALCHEMY_PHASE: "runtime",
485
+ HOST: "0.0.0.0",
486
+ };
487
+ return {
488
+ stables: ["sandboxId"],
489
+ diff: Effect.fn(function* ({ news, olds, output }) {
490
+ if (olds === undefined) return undefined;
491
+ // A program's props are partly unresolved at plan (its bindings'
492
+ // env holds Outputs); the replacing ones are compared once known.
493
+ if (isResolved(news) && replacingProps(olds) !== replacingProps(news)) {
494
+ return { action: "replace" } as const;
495
+ }
496
+ // The bundle depends only on `main`, `handler` and `build`, which
497
+ // are plain. A code change, or a process that ended (the sandbox
498
+ // stopped or rebooted), redeploys the program in place.
499
+ const props = news as SandboxProps;
500
+ if (typeof props.main === "string" && output !== undefined) {
501
+ const { hash } = yield* bundleProgram({ ...props, main: props.main });
502
+ if (output.program === undefined || hash !== output.program.codeHash) {
503
+ return { action: "update" } as const;
504
+ }
505
+ if (!(yield* isProgramRunning(output.sandboxId, output.program))) {
506
+ return { action: "update" } as const;
507
+ }
508
+ }
509
+ return undefined;
510
+ }),
511
+ read: Effect.fn(function* ({ id, output }) {
512
+ if (output === undefined) return undefined;
513
+ const sandbox = yield* observeSandbox(output.sandboxId);
514
+ if (sandbox === undefined) return undefined;
515
+ // The program isn't visible on the sandbox; keep what the last deploy recorded.
516
+ const attrs = {
517
+ ...toAttributes(sandbox),
518
+ url: output.url,
519
+ urlToken: output.urlToken,
520
+ program: output.program,
521
+ };
522
+ return (yield* hasOwnershipLabels(id, sandbox.labels)) ? attrs : Unowned(attrs);
523
+ }),
524
+ lookup: Effect.fn(function* ({ sandboxId }) {
525
+ const sandbox = yield* observeSandbox(sandboxId);
526
+ return sandbox === undefined ? undefined : toAttributes(sandbox);
527
+ }),
528
+ reconcile: Effect.fn(function* ({ id, news, output, bindings, session }) {
529
+ const name = yield* boatName(id, news.name ?? output?.name, 120);
530
+ const owner = yield* ownershipLabels(id);
531
+ const program = news.main !== undefined;
532
+ // A program serves for as long as the sandbox runs: don't stop it on a timer.
533
+ const ttlSeconds = ttlSecondsOf(news.autoStop, program);
534
+
535
+ // Observe — `output` caches the id; the sandbox may be gone.
536
+ let sandbox = output === undefined ? undefined : yield* observeSandbox(output.sandboxId);
537
+
538
+ // Ensure
539
+ if (sandbox === undefined) {
540
+ yield* session.note(`Creating sandbox ${name}...`);
541
+ const created = yield* boat.createSandbox({
542
+ ...(news.type === undefined ? {} : { type: news.type }),
543
+ ...(ttlSeconds === undefined ? {} : { ttlSeconds }),
544
+ // A program's env is its process's, written at deploy, not the sandbox's.
545
+ ...(news.env === undefined || program ? {} : { env: plainEnv(news.env) }),
546
+ ...(news.environment === undefined
547
+ ? {}
548
+ : { environment: resolved(news.environment).name }),
549
+ ...(news.noEnv === undefined ? {} : { noEnv: news.noEnv }),
550
+ ...(news.snapshots === undefined ? {} : { snapshots: news.snapshots }),
551
+ ...(news.monitoring === undefined ? {} : { monitoring: news.monitoring }),
552
+ ...(news.setupScript === undefined ? {} : { setupScript: news.setupScript }),
553
+ ...(news.snapshot === undefined ? {} : { from: resolved(news.snapshot).name }),
554
+ ...(news.visibility === undefined ? {} : { visibility: news.visibility }),
555
+ labels: { ...news.labels, ...owner },
556
+ });
557
+ sandbox = created.sandbox;
558
+ }
559
+
560
+ // Sync — a stopped program sandbox is resumed, so the program can run.
561
+ if (program && sandbox.state === "archived") {
562
+ yield* session.note(`Resuming sandbox ${name}...`);
563
+ const resumed = yield* boat.resumeSandbox({ sandboxId: sandbox.id });
564
+ sandbox = resumed.sandbox ?? sandbox;
565
+ }
566
+
567
+ // Sync — name, subdomain and auto-stop, against what Boat reports.
568
+ const ttlChanged =
569
+ ttlSeconds !== undefined && (sandbox.ttlSeconds ?? null) !== ttlSeconds;
570
+ const subdomainChanged =
571
+ news.subdomain !== undefined && sandbox.subdomain !== news.subdomain;
572
+ if (sandbox.name !== name || ttlChanged || subdomainChanged) {
573
+ const updated = yield* boat.updateSandbox({
574
+ sandboxId: sandbox.id,
575
+ ...(sandbox.name !== name ? { name } : {}),
576
+ ...(ttlChanged ? { ttlSeconds } : {}),
577
+ ...(subdomainChanged ? { subdomain: news.subdomain } : {}),
578
+ });
579
+ sandbox = updated.sandbox ?? sandbox;
580
+ }
581
+
582
+ // Sync — visibility, against what Boat reports.
583
+ if (
584
+ news.visibility !== undefined &&
585
+ (sandbox.visibility ?? "private") !== news.visibility
586
+ ) {
587
+ yield* session.note(
588
+ `Making sandbox ${name} ${news.visibility === "shared" ? "shared" : "private"}...`,
589
+ );
590
+ if (news.visibility === "shared") {
591
+ const shared = yield* boat.shareSandbox({ sandboxId: sandbox.id });
592
+ if (shared.restartRequired) {
593
+ yield* session.note(
594
+ `Sandbox ${name} is shared from its next start: stop and resume it to share it now.`,
595
+ );
596
+ }
597
+ } else {
598
+ yield* boat.unshareSandbox({ sandboxId: sandbox.id });
599
+ }
600
+ sandbox = (yield* observeSandbox(sandbox.id)) ?? sandbox;
601
+ }
602
+
603
+ const attrs = toAttributes(sandbox);
604
+ if (news.main === undefined) return attrs;
605
+
606
+ // Deploy the program into the running sandbox, then host its port.
607
+ const port = news.port ?? DEFAULT_PORT;
608
+ const state = yield* deployProgram({
609
+ id,
610
+ sandboxId: sandbox.id,
611
+ source: { ...news, main: news.main },
612
+ port,
613
+ env: { ...bindingEnv(bindings), ...alchemyEnv, PORT: String(port), ...news.env },
614
+ // A replaced sandbox runs nothing yet.
615
+ previous: output?.sandboxId === sandbox.id ? output.program : undefined,
616
+ note: session.note,
617
+ });
618
+ const hosted = yield* boat.hostPort({
619
+ sandboxId: sandbox.id,
620
+ port,
621
+ public: news.public ?? false,
622
+ });
623
+ const parsed = hosted.url === undefined ? undefined : new URL(hosted.url);
624
+ const token = parsed?.searchParams.get("_token") ?? undefined;
625
+ return {
626
+ ...attrs,
627
+ url: parsed?.origin,
628
+ urlToken: token === undefined ? undefined : Redacted.make(token),
629
+ program: state,
630
+ };
631
+ }),
632
+ delete: Effect.fn(function* ({ output }) {
633
+ yield* deleteSandbox(output.sandboxId);
634
+ }),
635
+ // Declared sandboxes and the ones bindings create at run time both
636
+ // carry `alchemy.stack`, so `nuke` finds both.
637
+ list: Effect.fn(function* () {
638
+ const all = yield* Stream.runCollect(boat.listSandboxes.items({ limit: 100 }));
639
+ return Array.from(all)
640
+ .filter(
641
+ (sandbox) => sandbox.state !== "cancelled" && hasAnyAlchemyLabel(sandbox.labels),
642
+ )
643
+ .map(toAttributes);
644
+ }),
645
+ };
646
+ }),
647
+ );