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

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 (149) 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 +186 -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 +249 -0
  114. package/lib/internal/SandboxHandle.js.map +1 -0
  115. package/lib/internal/WebhookDelivery.d.ts +16 -0
  116. package/lib/internal/WebhookDelivery.d.ts.map +1 -0
  117. package/lib/internal/WebhookDelivery.js +109 -0
  118. package/lib/internal/WebhookDelivery.js.map +1 -0
  119. package/package.json +67 -3
  120. package/src/AiProxy.ts +171 -0
  121. package/src/ApiKey.ts +250 -0
  122. package/src/AuthProvider.ts +472 -0
  123. package/src/BoatBindings.ts +136 -0
  124. package/src/Bootstrap.ts +55 -0
  125. package/src/ConnectSandbox.ts +60 -0
  126. package/src/ConnectSandboxHttp.ts +59 -0
  127. package/src/Contributions.ts +9 -0
  128. package/src/CreateSandbox.ts +143 -0
  129. package/src/CreateSandboxHttp.ts +115 -0
  130. package/src/CredentialProfile.ts +350 -0
  131. package/src/Credentials.ts +33 -0
  132. package/src/Environment.ts +416 -0
  133. package/src/Errors.ts +34 -0
  134. package/src/Providers.ts +71 -0
  135. package/src/Sandbox.ts +647 -0
  136. package/src/SandboxClient.ts +199 -0
  137. package/src/SandboxEvents.ts +125 -0
  138. package/src/SandboxEventsLive.ts +88 -0
  139. package/src/Snapshot.ts +284 -0
  140. package/src/Webhook.ts +163 -0
  141. package/src/globals.d.ts +2 -0
  142. package/src/index.ts +18 -0
  143. package/src/internal/Declared.ts +87 -0
  144. package/src/internal/Org.ts +31 -0
  145. package/src/internal/Ownership.ts +75 -0
  146. package/src/internal/Program.ts +250 -0
  147. package/src/internal/Ref.ts +15 -0
  148. package/src/internal/SandboxHandle.ts +425 -0
  149. package/src/internal/WebhookDelivery.ts +179 -0
@@ -0,0 +1,199 @@
1
+ import type * as Boat from "@distilled.cloud/boat-dev";
2
+ import type * as boat from "@distilled.cloud/boat-dev/boat";
3
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
4
+ import type * as Sandbox from "alchemy/Sandbox";
5
+ import type * as Duration from "effect/Duration";
6
+ import type * as Effect from "effect/Effect";
7
+ import type * as Redacted from "effect/Redacted";
8
+ import type * as Stream from "effect/Stream";
9
+ import type { BoatUnexpectedResponse, ExecTimedOut, SnapshotFailed } from "./Errors.ts";
10
+
11
+ /** Errors a Boat call can fail with. */
12
+ export type BoatError =
13
+ | Boat.BoatOpError
14
+ | boat.BadRequest
15
+ | boat.Forbidden
16
+ | boat.NotFound
17
+ | boat.Conflict
18
+ | boat.SandboxNotRunning
19
+ | boat.SandboxStartLimitExceeded
20
+ | boat.FileNotFound
21
+ | boat.ForkNeedsSnapshot
22
+ | ExecTimedOut
23
+ | SnapshotFailed
24
+ | BoatUnexpectedResponse;
25
+
26
+ /** A command: a shell line (run with `bash -lc`) or an argument vector. */
27
+ export type Command = Sandbox.Command;
28
+
29
+ /**
30
+ * `cwd` defaults to the home directory (`/home/user`). `timeout` (at most
31
+ * 10 minutes, Boat's default 30 seconds) stops the command and fails with
32
+ * `Boat.ExecTimedOut`.
33
+ */
34
+ export type ExecOptions = Sandbox.ExecOptions;
35
+
36
+ /** What a finished command printed, and its exit code. A non-zero exit code is returned, not failed. */
37
+ export interface ExecResult extends Sandbox.ExecResult {
38
+ /** Boat's id for the command, for its history and logs. */
39
+ readonly commandId: string | undefined;
40
+ }
41
+
42
+ export interface StartOptions {
43
+ /** Working directory. @default "/home/user" */
44
+ readonly cwd?: string;
45
+ /** Environment variables for the command. */
46
+ readonly env?: Readonly<Record<string, string>>;
47
+ }
48
+
49
+ /** A chunk of a process's output. */
50
+ export type ProcessOutput = Sandbox.ProcessOutput;
51
+
52
+ /**
53
+ * A command running in the background. It keeps running after the request
54
+ * that started it ends; `processes.attach(processId)` picks it up again.
55
+ * Boat has no stdin for background commands.
56
+ */
57
+ export interface Process {
58
+ /** Boat's process id, as a string. */
59
+ readonly id: string;
60
+ readonly processId: number;
61
+ /** stdout and stderr from where it was started or attached, until it exits. */
62
+ readonly output: Stream.Stream<ProcessOutput, BoatError, RuntimeContext>;
63
+ /** Stops the process. */
64
+ kill(): Effect.Effect<void, BoatError, RuntimeContext>;
65
+ /** Waits for it to exit, with the tail of what it printed. */
66
+ readonly wait: Effect.Effect<ExecResult, BoatError, RuntimeContext>;
67
+ }
68
+
69
+ /** Background commands in the sandbox. */
70
+ export interface Processes {
71
+ /** Picks up a background command by its process id. */
72
+ attach(processId: number): Effect.Effect<Process, BoatError, RuntimeContext>;
73
+ }
74
+
75
+ /** A file or directory in the sandbox. */
76
+ export interface EntryInfo extends Sandbox.FileEntry {
77
+ /** Octal permission bits, e.g. `0644`. */
78
+ readonly mode: string;
79
+ readonly modifiedAt: string;
80
+ }
81
+
82
+ /**
83
+ * The sandbox's filesystem. Relative paths resolve in the home directory
84
+ * (`/home/user`); Boat reaches only paths under `/home/user` and `/tmp`.
85
+ * A missing path fails with `FileNotFound`.
86
+ */
87
+ export interface Files extends Sandbox.Files<BoatError, EntryInfo> {
88
+ read(path: string): Effect.Effect<Uint8Array, BoatError, RuntimeContext>;
89
+ readText(path: string): Effect.Effect<string, BoatError, RuntimeContext>;
90
+ /** Writes a file, creating parent directories. Files up to 5 MiB. */
91
+ write(path: string, data: string | Uint8Array): Effect.Effect<void, BoatError, RuntimeContext>;
92
+ list(path: string): Effect.Effect<ReadonlyArray<EntryInfo>, BoatError, RuntimeContext>;
93
+ stat(path: string): Effect.Effect<EntryInfo, BoatError, RuntimeContext>;
94
+ /** Removes a file, or a directory and everything in it. */
95
+ remove(path: string): Effect.Effect<void, BoatError, RuntimeContext>;
96
+ /** Creates the directory and its parents. */
97
+ makeDir(path: string): Effect.Effect<void, BoatError, RuntimeContext>;
98
+ exists(path: string): Effect.Effect<boolean, BoatError, RuntimeContext>;
99
+ /** Moves a file or directory, creating the destination's parents. */
100
+ move(
101
+ source: string,
102
+ destination: string,
103
+ options?: { readonly overwrite?: boolean },
104
+ ): Effect.Effect<void, BoatError, RuntimeContext>;
105
+ }
106
+
107
+ /**
108
+ * A port inside a Boat sandbox, served at
109
+ * `https://<subdomain>-<port>.on.boat.dev`. A protected port needs its
110
+ * `token`, which `fetch` sends for you as Boat's `_port_auth` cookie.
111
+ */
112
+ export interface Port extends Sandbox.SandboxPort<BoatError> {
113
+ /** The port's URL, without its token. */
114
+ readonly url: string;
115
+ /** The port's access token, or `undefined` for a public port. */
116
+ readonly token: Redacted.Redacted<string> | undefined;
117
+ }
118
+
119
+ export interface PortOptions {
120
+ /** Serve the port without a token, to anyone. @default false */
121
+ readonly public?: boolean;
122
+ /** Set when the program listens on `127.0.0.1` only; Boat relays to it. @default false */
123
+ readonly localhost?: boolean;
124
+ }
125
+
126
+ /**
127
+ * What you can do with a sandbox, however you got it. Calls into a stopped
128
+ * sandbox (`exec`, `start`, `files`, `getTcpPort`) fail with
129
+ * `SandboxNotRunning`: `resume()` it first.
130
+ */
131
+ export interface SandboxOperations
132
+ extends
133
+ Sandbox.SandboxExec<BoatError, ExecResult, ExecOptions>,
134
+ Sandbox.SandboxFiles<BoatError, EntryInfo>,
135
+ Sandbox.SandboxPorts<BoatError, Port> {
136
+ /** Runs a command to completion. */
137
+ exec(
138
+ command: Command,
139
+ options?: ExecOptions,
140
+ ): Effect.Effect<ExecResult, BoatError, RuntimeContext>;
141
+ /** Starts a command in the background. */
142
+ start(
143
+ command: Command,
144
+ options?: StartOptions,
145
+ ): Effect.Effect<Process, BoatError, RuntimeContext>;
146
+ readonly processes: Processes;
147
+ readonly files: Files;
148
+ /** A port inside the sandbox. Boat hosts it on first use. */
149
+ getTcpPort(port: number, options?: PortOptions): Effect.Effect<Port, BoatError, RuntimeContext>;
150
+ /** Live details: state, size, environment, labels. */
151
+ describe(): Effect.Effect<boat.Sandbox, BoatError, RuntimeContext>;
152
+ /**
153
+ * Stops the sandbox, saving its disk. Stopping a stopped sandbox
154
+ * succeeds.
155
+ */
156
+ stop(): Effect.Effect<void, BoatError, RuntimeContext>;
157
+ /**
158
+ * Resumes a stopped sandbox (a no-op on a running one). `autoStop` sets
159
+ * how long it runs before stopping again; `false` never stops it.
160
+ */
161
+ resume(options?: {
162
+ readonly autoStop?: Duration.Input | false;
163
+ }): Effect.Effect<void, BoatError, RuntimeContext>;
164
+ /** Sets how long the sandbox runs from now before it stops; `false` never stops it. */
165
+ setAutoStop(autoStop: Duration.Input | false): Effect.Effect<void, BoatError, RuntimeContext>;
166
+ /** Saves the sandbox's disk as a named snapshot, and waits until it is ready. */
167
+ snapshot(name: string): Effect.Effect<boat.SavedSnapshot, BoatError, RuntimeContext>;
168
+ }
169
+
170
+ /**
171
+ * A sandbox created or connected at runtime (`CreateSandbox`). Holds only
172
+ * its id, so it can be rebuilt with `connect(id)` in any request.
173
+ */
174
+ export interface BoatSandbox extends SandboxOperations {
175
+ readonly sandboxId: string;
176
+ /** What Boat returned for the sandbox when it was created or connected. */
177
+ readonly info: boat.Sandbox;
178
+ /**
179
+ * A copy of the sandbox from its last completed snapshot. Fails with
180
+ * `ForkNeedsSnapshot` until it has one; stopping a sandbox snapshots it.
181
+ */
182
+ fork(
183
+ options?: Omit<boat.ForkSandboxRequest, "sandboxId" | "ttlSeconds"> & {
184
+ /** How long the fork runs before Boat stops it; `false` never stops it. */
185
+ readonly autoStop?: Duration.Input | false;
186
+ },
187
+ ): Effect.Effect<BoatSandbox, BoatError, RuntimeContext>;
188
+ /** Deletes the sandbox with its disk. Deleting one that is gone succeeds. */
189
+ delete(): Effect.Effect<void, BoatError, RuntimeContext>;
190
+ }
191
+
192
+ /**
193
+ * A sandbox declared in the stack (`ConnectSandbox`). The same operations
194
+ * as {@link BoatSandbox} except `fork` and `delete`, which its key can't
195
+ * do; its id is read at runtime.
196
+ */
197
+ export interface ConnectedSandbox extends SandboxOperations {
198
+ readonly sandboxId: Effect.Effect<string, never, RuntimeContext>;
199
+ }
@@ -0,0 +1,125 @@
1
+ import { sanitizeKey } from "alchemy/RuntimeContext";
2
+ import * as Context from "effect/Context";
3
+ import type * as Effect from "effect/Effect";
4
+ import type { WebhookEvent } from "./Webhook.ts";
5
+
6
+ /** A sandbox lifecycle event, as Boat delivers it to a webhook. */
7
+ export interface SandboxEvent {
8
+ /** The event's id. A redelivered event keeps it: deduplicate on it. */
9
+ readonly id: string;
10
+ readonly type: WebhookEvent;
11
+ readonly createdAt: string;
12
+ readonly data: {
13
+ readonly sandbox: {
14
+ readonly id: string;
15
+ readonly name?: string;
16
+ /**
17
+ * The sandbox's labels, read from Boat when the event is scoped to the
18
+ * stack. `undefined` with `scope: "account"`.
19
+ */
20
+ readonly labels?: Readonly<Record<string, string>>;
21
+ };
22
+ /** On `sandbox.ready`, `sandbox.error` and `sandbox.archived`. */
23
+ readonly previousState?: string;
24
+ /** The state the sandbox is in now. */
25
+ readonly state?: string;
26
+ /** On `sandbox.degraded` and `sandbox.recovered`: why. */
27
+ readonly reason?: string;
28
+ };
29
+ /** Which delivery attempt this is, from 1. */
30
+ readonly attempt: number;
31
+ }
32
+
33
+ export interface SandboxEventsProps {
34
+ /**
35
+ * The events to receive.
36
+ * @default every sandbox lifecycle event
37
+ */
38
+ readonly events?: ReadonlyArray<WebhookEvent>;
39
+ /**
40
+ * Whose sandboxes. Boat sends a webhook every event in the account.
41
+ * `"stack"` hands the handler only the sandboxes this stack and stage own
42
+ * (declared, or created by its bindings), and adds each sandbox's labels
43
+ * to the event; `"account"` hands it everything.
44
+ * @default "stack"
45
+ */
46
+ readonly scope?: "stack" | "account";
47
+ /**
48
+ * The path on the host Boat delivers to.
49
+ * @default "/__alchemy/boat/events"
50
+ */
51
+ readonly path?: string;
52
+ }
53
+
54
+ /** Receives sandbox lifecycle events on a host. */
55
+ export type SandboxEventsService = <Req = never>(
56
+ props: SandboxEventsProps,
57
+ process: (event: SandboxEvent) => Effect.Effect<void, never, Req>,
58
+ ) => Effect.Effect<void, never, never>;
59
+
60
+ /**
61
+ * Receive Boat sandbox lifecycle events (`sandbox.ready`,
62
+ * `sandbox.hydrated`, `sandbox.error`, `sandbox.archived`,
63
+ * `sandbox.degraded`, `sandbox.recovered`) in a Cloudflare Worker or any
64
+ * other webhook host.
65
+ *
66
+ * At deploy, a {@link Webhook} is created pointing at the host, and its
67
+ * signing secret is given to the host as a secret (a Cloudflare secret on
68
+ * a Worker). While the host runs,
69
+ * each delivery's `X-Ascii-Signature` (HMAC-SHA256 over the delivery id,
70
+ * timestamp and raw body) is checked before the handler runs; an unsigned,
71
+ * wrongly signed, altered or stale (over five minutes old) request is
72
+ * refused with 401, and bodies over 1 MB are refused unread. Boat retries a
73
+ * failed delivery, so handlers should be idempotent on `event.id`.
74
+ *
75
+ * Boat sends a webhook the events of every sandbox in the account, and an
76
+ * account has at most 10 webhooks. With the default `scope: "stack"`, the
77
+ * host looks each sandbox up with a key of its own, created at deploy and
78
+ * deleted with the host, that can only read sandboxes (`sandbox.read`), and drops
79
+ * events for sandboxes other stacks or stages own, and for sandboxes
80
+ * already deleted. The scope is a filter, not an access boundary: anyone
81
+ * in the account who can create sandboxes can label one with the stack's
82
+ * labels.
83
+ *
84
+ * ### Handling lifecycle events
85
+ * **Example:** Record when a sandbox stops
86
+ * ```typescript
87
+ * export default class Api extends Cloudflare.Worker<Api>()(
88
+ * "Api",
89
+ * { main: import.meta.url },
90
+ * Effect.gen(function* () {
91
+ * yield* Boat.consumeSandboxEvents({ events: ["sandbox.archived"] }, (event) =>
92
+ * Effect.log(`sandbox ${event.data.sandbox.id} stopped`),
93
+ * );
94
+ * return { fetch: Effect.succeed(HttpServerResponse.text("ok")) };
95
+ * }).pipe(Effect.provide(Boat.SandboxEventsLive)),
96
+ * ) {}
97
+ * ```
98
+ *
99
+ * **Example:** Every sandbox in the account
100
+ * ```typescript
101
+ * yield* Boat.consumeSandboxEvents({ scope: "account", events: ["sandbox.error"] }, (event) =>
102
+ * Effect.logError(`sandbox ${event.data.sandbox.id} failed`),
103
+ * );
104
+ * ```
105
+ *
106
+ * @binding
107
+ * @product Webhook
108
+ */
109
+ export class SandboxEvents extends Context.Service<SandboxEvents, SandboxEventsService>()(
110
+ "Boat.SandboxEvents",
111
+ ) {}
112
+
113
+ /** Subscribes the current host to sandbox lifecycle events. */
114
+ export const consumeSandboxEvents = <Req = never>(
115
+ props: SandboxEventsProps,
116
+ process: (event: SandboxEvent) => Effect.Effect<void, never, Req>,
117
+ ) => SandboxEvents.use((source) => source(props, process));
118
+
119
+ /** Where Boat delivers on the host. */
120
+ export const sandboxEventsPath = (props: SandboxEventsProps): string =>
121
+ props.path ?? "/__alchemy/boat/events";
122
+
123
+ /** The env var the signing secret is given to the host under. */
124
+ export const sandboxEventsSecretEnvName = (path: string): string =>
125
+ `ALCHEMY_BOAT_WEBHOOK_SECRET_${sanitizeKey(path)}`;
@@ -0,0 +1,88 @@
1
+ import { AlchemyContext } from "alchemy/AlchemyContext";
2
+ import * as Namespace from "alchemy/Namespace";
3
+ import * as Output from "alchemy/Output";
4
+ import { RuntimeContext } from "alchemy/RuntimeContext";
5
+ import { WebhookHost } from "alchemy/WebhookHost";
6
+ import * as Effect from "effect/Effect";
7
+ import * as Layer from "effect/Layer";
8
+ import * as Option from "effect/Option";
9
+ import { bindBoat, bindOwnerLabels } from "./BoatBindings.ts";
10
+ import { asSecret, handleDelivery } from "./internal/WebhookDelivery.ts";
11
+ import {
12
+ type SandboxEvent,
13
+ SandboxEvents,
14
+ type SandboxEventsProps,
15
+ type SandboxEventsService,
16
+ sandboxEventsPath,
17
+ sandboxEventsSecretEnvName,
18
+ } from "./SandboxEvents.ts";
19
+ import { ALL_SANDBOX_EVENTS, Webhook } from "./Webhook.ts";
20
+
21
+ /**
22
+ * {@link SandboxEvents} on any {@link WebhookHost}: every Cloudflare Worker
23
+ * is one.
24
+ *
25
+ * @layer
26
+ * @product Webhook
27
+ * @provides Boat.SandboxEvents
28
+ */
29
+ export const SandboxEventsLive = Layer.effect(
30
+ SandboxEvents,
31
+ Effect.gen(function* () {
32
+ const host = yield* WebhookHost;
33
+ // Yielding the resource class erases its `Providers` requirement; the
34
+ // stack satisfies it at plan time.
35
+ const createWebhook = yield* Webhook;
36
+
37
+ return Effect.fn(function* (
38
+ props: SandboxEventsProps,
39
+ process: (event: SandboxEvent) => Effect.Effect<void, never, any>,
40
+ ) {
41
+ const path = sandboxEventsPath(props);
42
+
43
+ // Boat generates the signing secret. It goes to the host as a binding,
44
+ // not a prop: the webhook depends on the host's URL.
45
+ const runtime = yield* RuntimeContext;
46
+ const secretKey = sandboxEventsSecretEnvName(path);
47
+ if (!globalThis.__ALCHEMY_RUNTIME__) {
48
+ yield* Namespace.push(
49
+ host.LogicalId,
50
+ Effect.gen(function* () {
51
+ // Boat delivers only to public HTTPS URLs, and under `alchemy dev`
52
+ // the host is on localhost: skip the webhook rather than fail.
53
+ const context = yield* Effect.serviceOption(AlchemyContext);
54
+ if (Option.isSome(context) && context.value.dev) {
55
+ yield* Effect.logWarning(
56
+ `Boat.consumeSandboxEvents: no webhook for ${path} under alchemy dev; Boat only delivers to public URLs, so deploy to receive events.`,
57
+ );
58
+ return;
59
+ }
60
+ const hook = yield* createWebhook(`BoatEvents${path}`, {
61
+ url: Output.interpolate`${host.url}${path}`,
62
+ events: [...(props.events ?? ALL_SANDBOX_EVENTS)],
63
+ });
64
+ yield* host.bind(`boat-webhook:${path}`, { env: { [secretKey]: hook.secret } });
65
+ }),
66
+ );
67
+ }
68
+ const boundSecret = Effect.map(runtime.get<unknown>(secretKey), asSecret);
69
+
70
+ // Scoped to the stack: the host reads each sandbox's labels with a
71
+ // key that may only read sandboxes.
72
+ const scope =
73
+ (props.scope ?? "stack") === "stack"
74
+ ? {
75
+ auth: yield* bindBoat("BoatEventsKey", "BOAT_EVENTS_KEY", {
76
+ actions: ["sandbox.read"],
77
+ }),
78
+ owner: yield* bindOwnerLabels,
79
+ }
80
+ : undefined;
81
+
82
+ const types = new Set<string>(props.events ?? ALL_SANDBOX_EVENTS);
83
+ yield* host.route(path, (request) =>
84
+ handleDelivery(request, boundSecret, types, scope, process),
85
+ );
86
+ }) as SandboxEventsService;
87
+ }),
88
+ );
@@ -0,0 +1,284 @@
1
+ import * as boat from "@distilled.cloud/boat-dev/boat";
2
+ import { Unowned } from "alchemy/AdoptPolicy";
3
+ import { isResolved } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import { sha256Object } from "alchemy/Util/sha256";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Schedule from "effect/Schedule";
9
+ import { SnapshotFailed, SnapshotSetupFailed } from "./Errors.ts";
10
+ import { declarable } from "./internal/Declared.ts";
11
+ import { boatName, isGeneratedName, ownershipLabels } from "./internal/Ownership.ts";
12
+ import type { Providers } from "./Providers.ts";
13
+ import type { SandboxType } from "./Sandbox.ts";
14
+
15
+ /** A snapshot to start from: a `Boat.Snapshot`, declared or yielded, or `{ name }`. */
16
+ export interface SnapshotBase {
17
+ name: string;
18
+ /** A `Boat.Snapshot`'s setup hash. A rebuild replaces what starts from it. */
19
+ setupHash?: string;
20
+ }
21
+
22
+ export interface SnapshotProps {
23
+ /**
24
+ * Shell commands run in order before the snapshot is saved: clone a
25
+ * repository, install dependencies, warm a cache. A non-zero exit fails
26
+ * the deploy with `SnapshotSetupFailed`.
27
+ */
28
+ run: string[];
29
+ /**
30
+ * The snapshot to start from: another `Boat.Snapshot`, or `{ name }` of
31
+ * one saved elsewhere. Omit to start from a fresh sandbox. Rebuilding it
32
+ * takes this snapshot again.
33
+ */
34
+ from?: SnapshotBase;
35
+ /** Environment variables for the setup commands. */
36
+ env?: Record<string, string>;
37
+ /** Working directory for the setup commands. @default "/home/user" */
38
+ cwd?: string;
39
+ /**
40
+ * Machine size to build on. Sandboxes started from the snapshot default
41
+ * to it.
42
+ * @default "default"
43
+ */
44
+ type?: SandboxType;
45
+ /**
46
+ * Snapshot name: lowercase letters, digits and dashes, up to 63. If
47
+ * omitted, a unique name is generated from the stack, stage and logical
48
+ * ID. Changing it replaces the snapshot.
49
+ */
50
+ name?: string;
51
+ }
52
+
53
+ export interface SnapshotAttributes {
54
+ /** The name sandboxes start from. */
55
+ name: string;
56
+ /** Boat's id for the saved disk. */
57
+ snapshotId: string | undefined;
58
+ /** The machine size it was saved from. */
59
+ type: string | undefined;
60
+ /** Restored size, in bytes. */
61
+ sizeBytes: number | undefined;
62
+ /** Content hash of the base and setup. A deploy re-saves only when it changes. */
63
+ setupHash: string;
64
+ }
65
+
66
+ export interface Snapshot extends Resource<
67
+ "Boat.Snapshot",
68
+ SnapshotProps,
69
+ SnapshotAttributes,
70
+ never,
71
+ Providers,
72
+ { name: string }
73
+ > {}
74
+
75
+ /**
76
+ * A warm base for sandboxes: a sandbox set up with your commands and its
77
+ * disk saved under a name. Sandboxes started from it begin exactly where
78
+ * the setup left off: packages installed, a repository cloned, a build
79
+ * cache warm.
80
+ *
81
+ * Use a snapshot for work that is too slow to repeat per sandbox. Alchemy
82
+ * runs the commands in a sandbox of its own, saves it, and deletes the
83
+ * sandbox. A deploy re-runs the setup only when the commands or the base
84
+ * change, and a rebuild replaces the declared sandboxes started from it.
85
+ *
86
+ * ### Creating a Snapshot
87
+ * **Example:** A repository with its dependencies installed
88
+ * ```typescript
89
+ * export const Golden = Boat.Snapshot("Golden", {
90
+ * run: [
91
+ * "git clone https://github.com/alchemy-run/alchemy ~/alchemy",
92
+ * "cd ~/alchemy && pnpm install",
93
+ * ],
94
+ * });
95
+ * ```
96
+ *
97
+ * **Example:** Layer one snapshot on another
98
+ * ```typescript
99
+ * export const Tools = Boat.Snapshot("Tools", { run: ["npm i -g pnpm"] });
100
+ * export const Golden = Boat.Snapshot("Golden", { from: Tools, run: ["pnpm dlx playwright install"] });
101
+ * ```
102
+ *
103
+ * ### Starting sandboxes from a Snapshot
104
+ * **Example:** A declared sandbox
105
+ * ```typescript
106
+ * export const Box = Boat.Sandbox("Box", { snapshot: Golden });
107
+ * ```
108
+ *
109
+ * **Example:** Sandboxes created at run time
110
+ * ```typescript
111
+ * const sandboxes = yield* Boat.CreateSandbox(Golden);
112
+ * const sandbox = yield* sandboxes.create({ autoStop: "10 minutes" });
113
+ * ```
114
+ *
115
+ * ### Referencing a snapshot the stack doesn't manage
116
+ * **Example:** Look one up by name
117
+ * ```typescript
118
+ * const shared = Boat.Snapshot.lookup({ name: "team-base" });
119
+ * const sandboxes = yield* Boat.CreateSandbox(shared);
120
+ * ```
121
+ *
122
+ * @resource
123
+ * @product Snapshot
124
+ */
125
+ export const Snapshot = declarable<Snapshot>()(Resource<Snapshot>("Boat.Snapshot"));
126
+
127
+ /** Boat snapshot names: lowercase letters, digits and dashes. */
128
+ const snapshotName = (id: string, name: string | undefined) =>
129
+ Effect.map(boatName(id, name, 63), (value) =>
130
+ name !== undefined ? value : value.replace(/[^a-z0-9-]/g, "-"),
131
+ );
132
+
133
+ const setupHashOf = (props: SnapshotProps) =>
134
+ Effect.map(
135
+ sha256Object({
136
+ from: props.from === undefined ? null : [props.from.name, props.from.setupHash ?? null],
137
+ run: props.run,
138
+ env: props.env ?? {},
139
+ cwd: props.cwd ?? null,
140
+ type: props.type ?? "default",
141
+ }),
142
+ (hash) => hash.slice(0, 16),
143
+ );
144
+
145
+ const observeSnapshot = (name: string) =>
146
+ boat.getSavedSnapshot({ name }).pipe(
147
+ Effect.map((got): boat.SavedSnapshot | undefined => got.snapshot),
148
+ Effect.catchTag("NotFound", () => Effect.undefined),
149
+ );
150
+
151
+ const toAttributes = (snapshot: boat.SavedSnapshot, setupHash: string): SnapshotAttributes => ({
152
+ name: snapshot.name,
153
+ snapshotId: snapshot.snapshotId,
154
+ type: snapshot.type,
155
+ sizeBytes: snapshot.sizeBytes,
156
+ setupHash,
157
+ });
158
+
159
+ /** POSIX single quotes: the value verbatim. */
160
+ const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
161
+
162
+ /** Saving takes seconds; poll until Boat reports `ready` or `failed` (about 90s at most). */
163
+ const awaitSaved = (name: string) =>
164
+ boat.getSavedSnapshot({ name }).pipe(
165
+ Effect.map((got) => got.snapshot),
166
+ Effect.repeat({
167
+ schedule: Schedule.spaced("3 seconds"),
168
+ until: (snapshot) => snapshot.status !== "saving",
169
+ times: 30,
170
+ }),
171
+ Effect.flatMap((snapshot) =>
172
+ snapshot.status === "ready"
173
+ ? Effect.succeed(snapshot)
174
+ : new SnapshotFailed({
175
+ snapshot: name,
176
+ message:
177
+ snapshot.status === "failed"
178
+ ? `Boat could not save snapshot ${name}: ${snapshot.error ?? "unknown error"}`
179
+ : `Boat snapshot ${name} was still saving after 90 seconds`,
180
+ }),
181
+ ),
182
+ );
183
+
184
+ /**
185
+ * Starts a setup sandbox (from `from`, with none of the account's secrets),
186
+ * runs the commands, saves it as `name`, and always deletes the sandbox.
187
+ */
188
+ const takeSnapshot = Effect.fn(function* (id: string, name: string, props: SnapshotProps) {
189
+ const labels = yield* ownershipLabels(id);
190
+ return yield* Effect.acquireUseRelease(
191
+ boat.createSandbox({
192
+ noEnv: true,
193
+ ttlSeconds: 1800,
194
+ ...(props.type === undefined ? {} : { type: props.type }),
195
+ ...(props.from === undefined ? {} : { from: props.from.name }),
196
+ labels,
197
+ }),
198
+ ({ sandbox }) =>
199
+ Effect.gen(function* () {
200
+ const exports = Object.entries(props.env ?? {})
201
+ .map(([key, value]) => `export ${key}=${quote(value)}; `)
202
+ .join("");
203
+ for (const command of props.run) {
204
+ const result = yield* boat.executeSandboxCommand({
205
+ sandboxId: sandbox.id,
206
+ command: `${exports}${command}`,
207
+ timeoutSeconds: 600,
208
+ ...(props.cwd === undefined ? {} : { cwd: props.cwd }),
209
+ });
210
+ const exitCode = "exitCode" in result ? (result.exitCode ?? -1) : -1;
211
+ const timedOut = "timedOut" in result && result.timedOut === true;
212
+ if (exitCode !== 0 || timedOut) {
213
+ return yield* new SnapshotSetupFailed({
214
+ message: timedOut
215
+ ? `snapshot ${name}: \`${command}\` ran past 10 minutes`
216
+ : `snapshot ${name}: \`${command}\` exited with ${exitCode}`,
217
+ command,
218
+ exitCode,
219
+ stderr: "stderr" in result ? result.stderr.slice(-4000) : "",
220
+ });
221
+ }
222
+ }
223
+ yield* boat.saveSavedSnapshot({ sandboxId: sandbox.id, name });
224
+ return yield* awaitSaved(name);
225
+ }),
226
+ ({ sandbox }) =>
227
+ Effect.ignore(boat.deleteSandbox({ sandboxId: sandbox.id, xAsciiConfirmDelete: sandbox.id })),
228
+ );
229
+ });
230
+
231
+ export const SnapshotProvider = () =>
232
+ Provider.effect(
233
+ Snapshot,
234
+ Effect.gen(function* () {
235
+ return {
236
+ stables: ["name"],
237
+ diff: Effect.fn(function* ({ id, news, olds, output }) {
238
+ if (!isResolved(news) || olds === undefined) return undefined;
239
+ const name = yield* snapshotName(id, news.name ?? output?.name);
240
+ if (output !== undefined && name !== output.name) return { action: "replace" } as const;
241
+ if (output !== undefined && (yield* setupHashOf(news)) !== output.setupHash) {
242
+ return { action: "update" } as const;
243
+ }
244
+ return undefined;
245
+ }),
246
+ read: Effect.fn(function* ({ id, olds, output }) {
247
+ const name = output?.name ?? (yield* snapshotName(id, olds?.name));
248
+ const snapshot = yield* observeSnapshot(name);
249
+ if (snapshot === undefined) return undefined;
250
+ const attrs = toAttributes(snapshot, output?.setupHash ?? "");
251
+ return output !== undefined || isGeneratedName(snapshot.name) ? attrs : Unowned(attrs);
252
+ }),
253
+ lookup: Effect.fn(function* ({ name }) {
254
+ const snapshot = yield* observeSnapshot(name);
255
+ return snapshot === undefined ? undefined : toAttributes(snapshot, "");
256
+ }),
257
+ reconcile: Effect.fn(function* ({ id, news, output, session }) {
258
+ const name = yield* snapshotName(id, news.name ?? output?.name);
259
+ const setupHash = yield* setupHashOf(news);
260
+
261
+ // Observe — a ready snapshot of this exact setup is kept.
262
+ const observed = yield* observeSnapshot(name);
263
+ if (observed?.status === "ready" && output?.setupHash === setupHash) {
264
+ return toAttributes(observed, setupHash);
265
+ }
266
+
267
+ // Ensure — saving under an existing name replaces its disk.
268
+ yield* session.note(`Building snapshot ${name}...`);
269
+ return toAttributes(yield* takeSnapshot(id, name, news), setupHash);
270
+ }),
271
+ delete: Effect.fn(function* ({ output }) {
272
+ yield* boat
273
+ .deleteSavedSnapshot({ name: output.name })
274
+ .pipe(Effect.catchTag("NotFound", () => Effect.void));
275
+ }),
276
+ list: Effect.fn(function* () {
277
+ const listed = yield* boat.listSavedSnapshots({});
278
+ return listed.snapshots
279
+ .filter((snapshot) => isGeneratedName(snapshot.name))
280
+ .map((snapshot) => toAttributes(snapshot, ""));
281
+ }),
282
+ };
283
+ }),
284
+ );