@alchemy.run/archil 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 (184) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +57 -4
  3. package/lib/AWS/S3Disk.d.ts +68 -0
  4. package/lib/AWS/S3Disk.d.ts.map +1 -0
  5. package/lib/AWS/S3Disk.js +100 -0
  6. package/lib/AWS/S3Disk.js.map +1 -0
  7. package/lib/AWS/index.d.ts +2 -0
  8. package/lib/AWS/index.d.ts.map +1 -0
  9. package/lib/AWS/index.js +2 -0
  10. package/lib/AWS/index.js.map +1 -0
  11. package/lib/ApiToken.d.ts +63 -0
  12. package/lib/ApiToken.d.ts.map +1 -0
  13. package/lib/ApiToken.js +121 -0
  14. package/lib/ApiToken.js.map +1 -0
  15. package/lib/ArchilBindings.d.ts +65 -0
  16. package/lib/ArchilBindings.d.ts.map +1 -0
  17. package/lib/ArchilBindings.js +128 -0
  18. package/lib/ArchilBindings.js.map +1 -0
  19. package/lib/AuthProvider.d.ts +40 -0
  20. package/lib/AuthProvider.d.ts.map +1 -0
  21. package/lib/AuthProvider.js +87 -0
  22. package/lib/AuthProvider.js.map +1 -0
  23. package/lib/Bootstrap.d.ts +8 -0
  24. package/lib/Bootstrap.d.ts.map +1 -0
  25. package/lib/Bootstrap.js +27 -0
  26. package/lib/Bootstrap.js.map +1 -0
  27. package/lib/Branch.d.ts +101 -0
  28. package/lib/Branch.d.ts.map +1 -0
  29. package/lib/Branch.js +117 -0
  30. package/lib/Branch.js.map +1 -0
  31. package/lib/BranchDisk.d.ts +42 -0
  32. package/lib/BranchDisk.d.ts.map +1 -0
  33. package/lib/BranchDisk.js +14 -0
  34. package/lib/BranchDisk.js.map +1 -0
  35. package/lib/Checkpoint.d.ts +78 -0
  36. package/lib/Checkpoint.d.ts.map +1 -0
  37. package/lib/Checkpoint.js +95 -0
  38. package/lib/Checkpoint.js.map +1 -0
  39. package/lib/Clients.d.ts +369 -0
  40. package/lib/Clients.d.ts.map +1 -0
  41. package/lib/Clients.js +334 -0
  42. package/lib/Clients.js.map +1 -0
  43. package/lib/ConnectSandbox.d.ts +69 -0
  44. package/lib/ConnectSandbox.d.ts.map +1 -0
  45. package/lib/ConnectSandbox.js +21 -0
  46. package/lib/ConnectSandbox.js.map +1 -0
  47. package/lib/Contributions.d.ts +7 -0
  48. package/lib/Contributions.d.ts.map +1 -0
  49. package/lib/Contributions.js +6 -0
  50. package/lib/Contributions.js.map +1 -0
  51. package/lib/CreateDisk.d.ts +90 -0
  52. package/lib/CreateDisk.d.ts.map +1 -0
  53. package/lib/CreateDisk.js +47 -0
  54. package/lib/CreateDisk.js.map +1 -0
  55. package/lib/CreateImage.d.ts +42 -0
  56. package/lib/CreateImage.d.ts.map +1 -0
  57. package/lib/CreateImage.js +19 -0
  58. package/lib/CreateImage.js.map +1 -0
  59. package/lib/CreateSandbox.d.ts +104 -0
  60. package/lib/CreateSandbox.d.ts.map +1 -0
  61. package/lib/CreateSandbox.js +98 -0
  62. package/lib/CreateSandbox.js.map +1 -0
  63. package/lib/Credentials.d.ts +44 -0
  64. package/lib/Credentials.d.ts.map +1 -0
  65. package/lib/Credentials.js +57 -0
  66. package/lib/Credentials.js.map +1 -0
  67. package/lib/Disk.d.ts +283 -0
  68. package/lib/Disk.d.ts.map +1 -0
  69. package/lib/Disk.js +294 -0
  70. package/lib/Disk.js.map +1 -0
  71. package/lib/DiskObjectsHttp.d.ts +20 -0
  72. package/lib/DiskObjectsHttp.d.ts.map +1 -0
  73. package/lib/DiskObjectsHttp.js +51 -0
  74. package/lib/DiskObjectsHttp.js.map +1 -0
  75. package/lib/DiskUser.d.ts +189 -0
  76. package/lib/DiskUser.d.ts.map +1 -0
  77. package/lib/DiskUser.js +243 -0
  78. package/lib/DiskUser.js.map +1 -0
  79. package/lib/ExecDisk.d.ts +57 -0
  80. package/lib/ExecDisk.d.ts.map +1 -0
  81. package/lib/ExecDisk.js +3 -0
  82. package/lib/ExecDisk.js.map +1 -0
  83. package/lib/ExecDiskHttp.d.ts +5 -0
  84. package/lib/ExecDiskHttp.d.ts.map +1 -0
  85. package/lib/ExecDiskHttp.js +18 -0
  86. package/lib/ExecDiskHttp.js.map +1 -0
  87. package/lib/ExecDisks.d.ts +54 -0
  88. package/lib/ExecDisks.d.ts.map +1 -0
  89. package/lib/ExecDisks.js +37 -0
  90. package/lib/ExecDisks.js.map +1 -0
  91. package/lib/Image.d.ts +188 -0
  92. package/lib/Image.d.ts.map +1 -0
  93. package/lib/Image.js +191 -0
  94. package/lib/Image.js.map +1 -0
  95. package/lib/InferEnv.d.ts +26 -0
  96. package/lib/InferEnv.d.ts.map +1 -0
  97. package/lib/InferEnv.js +1 -0
  98. package/lib/InferEnv.js.map +1 -0
  99. package/lib/MountDisk.d.ts +190 -0
  100. package/lib/MountDisk.d.ts.map +1 -0
  101. package/lib/MountDisk.js +227 -0
  102. package/lib/MountDisk.js.map +1 -0
  103. package/lib/Providers.d.ts +25 -0
  104. package/lib/Providers.d.ts.map +1 -0
  105. package/lib/Providers.js +48 -0
  106. package/lib/Providers.js.map +1 -0
  107. package/lib/ReadDisk.d.ts +55 -0
  108. package/lib/ReadDisk.d.ts.map +1 -0
  109. package/lib/ReadDisk.js +3 -0
  110. package/lib/ReadDisk.js.map +1 -0
  111. package/lib/ReadWriteDisk.d.ts +26 -0
  112. package/lib/ReadWriteDisk.d.ts.map +1 -0
  113. package/lib/ReadWriteDisk.js +3 -0
  114. package/lib/ReadWriteDisk.js.map +1 -0
  115. package/lib/Sandbox.d.ts +507 -0
  116. package/lib/Sandbox.d.ts.map +1 -0
  117. package/lib/Sandbox.js +729 -0
  118. package/lib/Sandbox.js.map +1 -0
  119. package/lib/SandboxOptions.d.ts +185 -0
  120. package/lib/SandboxOptions.d.ts.map +1 -0
  121. package/lib/SandboxOptions.js +102 -0
  122. package/lib/SandboxOptions.js.map +1 -0
  123. package/lib/SandboxPortToken.d.ts +64 -0
  124. package/lib/SandboxPortToken.d.ts.map +1 -0
  125. package/lib/SandboxPortToken.js +77 -0
  126. package/lib/SandboxPortToken.js.map +1 -0
  127. package/lib/WriteDisk.d.ts +37 -0
  128. package/lib/WriteDisk.d.ts.map +1 -0
  129. package/lib/WriteDisk.js +3 -0
  130. package/lib/WriteDisk.js.map +1 -0
  131. package/lib/index.d.ts +29 -0
  132. package/lib/index.d.ts.map +1 -0
  133. package/lib/index.js +28 -0
  134. package/lib/index.js.map +1 -0
  135. package/lib/internal/DeployerKey.d.ts +21 -0
  136. package/lib/internal/DeployerKey.d.ts.map +1 -0
  137. package/lib/internal/DeployerKey.js +34 -0
  138. package/lib/internal/DeployerKey.js.map +1 -0
  139. package/lib/internal/archil.d.ts +87 -0
  140. package/lib/internal/archil.d.ts.map +1 -0
  141. package/lib/internal/archil.js +154 -0
  142. package/lib/internal/archil.js.map +1 -0
  143. package/lib/internal/hosted.d.ts +90 -0
  144. package/lib/internal/hosted.d.ts.map +1 -0
  145. package/lib/internal/hosted.js +291 -0
  146. package/lib/internal/hosted.js.map +1 -0
  147. package/package.json +79 -3
  148. package/src/AWS/S3Disk.ts +125 -0
  149. package/src/AWS/index.ts +1 -0
  150. package/src/ApiToken.ts +168 -0
  151. package/src/ArchilBindings.ts +179 -0
  152. package/src/AuthProvider.ts +122 -0
  153. package/src/Bootstrap.ts +54 -0
  154. package/src/Branch.ts +199 -0
  155. package/src/BranchDisk.ts +57 -0
  156. package/src/Checkpoint.ts +145 -0
  157. package/src/Clients.ts +954 -0
  158. package/src/ConnectSandbox.ts +91 -0
  159. package/src/Contributions.ts +9 -0
  160. package/src/CreateDisk.ts +165 -0
  161. package/src/CreateImage.ts +73 -0
  162. package/src/CreateSandbox.ts +258 -0
  163. package/src/Credentials.ts +101 -0
  164. package/src/Disk.ts +468 -0
  165. package/src/DiskObjectsHttp.ts +64 -0
  166. package/src/DiskUser.ts +369 -0
  167. package/src/ExecDisk.ts +62 -0
  168. package/src/ExecDiskHttp.ts +25 -0
  169. package/src/ExecDisks.ts +100 -0
  170. package/src/Image.ts +316 -0
  171. package/src/InferEnv.ts +27 -0
  172. package/src/MountDisk.ts +466 -0
  173. package/src/Providers.ts +75 -0
  174. package/src/ReadDisk.ts +61 -0
  175. package/src/ReadWriteDisk.ts +31 -0
  176. package/src/Sandbox.ts +1064 -0
  177. package/src/SandboxOptions.ts +230 -0
  178. package/src/SandboxPortToken.ts +125 -0
  179. package/src/WriteDisk.ts +42 -0
  180. package/src/globals.d.ts +2 -0
  181. package/src/index.ts +59 -0
  182. package/src/internal/DeployerKey.ts +63 -0
  183. package/src/internal/archil.ts +273 -0
  184. package/src/internal/hosted.ts +402 -0
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Shared Archil helpers: physical names, lookups by name, readiness waits.
3
+ * Archil has no tags or labels, so ownership is the engine-generated
4
+ * physical name (stack, stage, logical id and instance suffix).
5
+ */
6
+ import * as archil from "@distilled.cloud/archil";
7
+ import { createPhysicalName } from "alchemy/PhysicalName";
8
+ import * as Data from "effect/Data";
9
+ import * as Duration from "effect/Duration";
10
+ import * as Effect from "effect/Effect";
11
+ import * as Option from "effect/Option";
12
+ import * as Schedule from "effect/Schedule";
13
+ import * as Stream from "effect/Stream";
14
+
15
+ /** Disk names: letters, digits, `-` and `_`, at most 100 characters. */
16
+ export const diskName = (id: string, name: string | undefined) =>
17
+ name ? Effect.succeed(name) : createPhysicalName({ id, maxLength: 100, delimiter: "-" });
18
+
19
+ /** Lowercase letters, digits and single `-`, trimmed of leading/trailing `-`. */
20
+ export const toDnsLabel = (value: string) =>
21
+ value
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9-]+/g, "-")
24
+ .replace(/-+/g, "-")
25
+ .replace(/^-|-$/g, "");
26
+
27
+ /** Sandbox names: lowercase letters, digits and `-`, at most 63 characters. */
28
+ export const sandboxName = (id: string, name: string | undefined) =>
29
+ name
30
+ ? Effect.succeed(name)
31
+ : createPhysicalName({ id, maxLength: 63, delimiter: "-", lowercase: true }).pipe(
32
+ Effect.map(toDnsLabel),
33
+ );
34
+
35
+ /** API token names. */
36
+ export const tokenName = (id: string, name: string | undefined) =>
37
+ name ? Effect.succeed(name) : createPhysicalName({ id, maxLength: 64, delimiter: "-" });
38
+
39
+ /**
40
+ * Whether a name was generated by Alchemy's physical-name scheme: it ends
41
+ * with a delimiter and a base32 instance suffix.
42
+ */
43
+ export const isAlchemyName = (name: string | undefined): boolean =>
44
+ name !== undefined && /[-_][a-z2-7]{16}$/i.test(name);
45
+
46
+ /**
47
+ * Whether a name carries a run-time client's owner prefix,
48
+ * `alc-<id>-<hash of stack/stage/id>-` (see `runtimePrefix`). `list` (and so
49
+ * `pnpm nuke`) finds disks and sandboxes created at run time by it.
50
+ */
51
+ export const isRuntimeObjectName = (name: string | undefined): boolean =>
52
+ name !== undefined && /^alc-[a-z0-9-]{1,14}-[0-9a-f]{8}-/.test(name);
53
+
54
+ /** A resource didn't reach a usable state within its time budget. */
55
+ export class ArchilNotReady extends Data.TaggedError("Archil.NotReady")<{
56
+ readonly resource: string;
57
+ readonly id: string;
58
+ readonly status: string;
59
+ readonly message: string;
60
+ }> {}
61
+
62
+ /** A resource reached a terminal failure state. */
63
+ export class ArchilFailed extends Data.TaggedError("Archil.Failed")<{
64
+ readonly resource: string;
65
+ readonly id: string;
66
+ readonly status: string;
67
+ readonly message: string;
68
+ }> {}
69
+
70
+ /**
71
+ * Poll `observe` every 2 s (at most ~90 s) until `check` returns a value;
72
+ * `check` returns `undefined` to keep waiting or fails on a terminal state.
73
+ */
74
+ export const pollUntil = <A, B, E extends { readonly _tag: string }, R>(
75
+ observe: Effect.Effect<A, E, R>,
76
+ check: (value: A) => Effect.Effect<B | undefined, ArchilFailed>,
77
+ pending: (value: A) => ArchilNotReady,
78
+ ): Effect.Effect<B, E | ArchilFailed | ArchilNotReady, R> =>
79
+ observe.pipe(
80
+ Effect.flatMap((value) =>
81
+ check(value).pipe(
82
+ Effect.flatMap((done) =>
83
+ done === undefined ? Effect.fail(pending(value)) : Effect.succeed(done),
84
+ ),
85
+ ),
86
+ ),
87
+ Effect.retry({
88
+ while: (e) => e._tag === "Archil.NotReady",
89
+ schedule: Schedule.spaced(Duration.seconds(2)),
90
+ times: 45,
91
+ }),
92
+ );
93
+
94
+ /** Every disk (or every disk with this exact name), following `nextCursor`. */
95
+ export const listAllDisks = (name?: string) =>
96
+ Stream.paginate(undefined as string | undefined, (cursor) =>
97
+ archil
98
+ .listDisks({ name, cursor, limit: 1000 })
99
+ .pipe(
100
+ Effect.map(({ data, nextCursor }) => [data, Option.fromNullishOr(nextCursor)] as const),
101
+ ),
102
+ );
103
+
104
+ /**
105
+ * Find a live disk by exact name. Archil keeps listing a deleted disk under
106
+ * its name for a while, so those are skipped.
107
+ */
108
+ export const findDiskByName = Effect.fn(function* (name: string) {
109
+ return Option.getOrUndefined(
110
+ yield* listAllDisks(name).pipe(
111
+ Stream.filter((disk) => disk.name === name && disk.status !== "deleted"),
112
+ Stream.runHead,
113
+ ),
114
+ );
115
+ });
116
+
117
+ /** Get a disk by id; `undefined` when it doesn't exist. */
118
+ export const getDisk = (id: string) =>
119
+ archil.getDisk({ id }).pipe(
120
+ Effect.map(({ data }) => (data.status === "deleted" ? undefined : data)),
121
+ Effect.catchTag("NotFound", () => Effect.undefined),
122
+ );
123
+
124
+ /** Wait until a disk is `available`. */
125
+ export const waitForDisk = (id: string) =>
126
+ pollUntil(
127
+ archil.getDisk({ id }).pipe(Effect.map(({ data }) => data)),
128
+ (disk) =>
129
+ disk.status === "available"
130
+ ? Effect.succeed(disk)
131
+ : disk.status === "failed"
132
+ ? Effect.fail(
133
+ new ArchilFailed({
134
+ resource: "Disk",
135
+ id,
136
+ status: disk.status,
137
+ message: `Disk ${disk.name} failed to provision`,
138
+ }),
139
+ )
140
+ : Effect.undefined,
141
+ (disk) =>
142
+ new ArchilNotReady({
143
+ resource: "Disk",
144
+ id,
145
+ status: disk.status,
146
+ message: `Disk ${disk.name} is ${disk.status}`,
147
+ }),
148
+ );
149
+
150
+ /** Wait until a disk is gone after a delete. */
151
+ export const waitForDiskGone = (id: string) =>
152
+ pollUntil(
153
+ getDisk(id),
154
+ (disk) => (disk === undefined ? Effect.succeed(true as const) : Effect.undefined),
155
+ (disk) =>
156
+ new ArchilNotReady({
157
+ resource: "Disk",
158
+ id,
159
+ status: disk?.status ?? "deleted",
160
+ message: `Disk ${disk?.name ?? id} is still ${disk?.status}`,
161
+ }),
162
+ ).pipe(Effect.asVoid);
163
+
164
+ /** Every sandbox in the account (optionally only those mounting a disk). */
165
+ export const listSandboxes = Effect.fn(function* (filesystem?: string) {
166
+ const { data } = yield* archil.listSandboxes({ filesystem });
167
+ return data.sandboxes ?? [];
168
+ });
169
+
170
+ /** Find a sandbox by exact name. */
171
+ export const findSandboxByName = Effect.fn(function* (name: string) {
172
+ return (yield* listSandboxes()).find(
173
+ (sandbox) => sandbox.name === name && sandbox.status !== "deleted",
174
+ );
175
+ });
176
+
177
+ /** Get a sandbox by id; `undefined` when it doesn't exist. */
178
+ export const getSandbox = (sid: string) =>
179
+ archil.getSandbox({ sid }).pipe(
180
+ Effect.map(({ data }) => (data.status === "deleted" ? undefined : data)),
181
+ Effect.catchTag("NotFound", () => Effect.undefined),
182
+ );
183
+
184
+ const settledSandboxStates = new Set(["running", "paused", "stopped", "exited"]);
185
+
186
+ /** Wait until a sandbox leaves its transitional states. */
187
+ export const waitForSandbox = (sid: string) =>
188
+ pollUntil(
189
+ archil.getSandbox({ sid }).pipe(Effect.map(({ data }) => data)),
190
+ (sandbox) =>
191
+ settledSandboxStates.has(sandbox.status)
192
+ ? Effect.succeed(sandbox)
193
+ : sandbox.status === "failed"
194
+ ? Effect.fail(
195
+ new ArchilFailed({
196
+ resource: "Sandbox",
197
+ id: sid,
198
+ status: sandbox.status,
199
+ message: `Sandbox ${sandbox.name} failed${sandbox.exit_reason ? `: ${sandbox.exit_reason}` : ""}`,
200
+ }),
201
+ )
202
+ : Effect.undefined,
203
+ (sandbox) =>
204
+ new ArchilNotReady({
205
+ resource: "Sandbox",
206
+ id: sid,
207
+ status: sandbox.status,
208
+ message: `Sandbox ${sandbox.name} is ${sandbox.status}`,
209
+ }),
210
+ );
211
+
212
+ /** States a sandbox can be deleted from. */
213
+ export const deletableSandboxStates = new Set(["stopped", "exited", "failed"]);
214
+
215
+ /** Wait until a stopping sandbox is stopped (or exited, or failed). */
216
+ export const waitForSandboxStopped = (sid: string) =>
217
+ pollUntil(
218
+ getSandbox(sid),
219
+ (sandbox) =>
220
+ sandbox === undefined || deletableSandboxStates.has(sandbox.status)
221
+ ? Effect.succeed(true as const)
222
+ : Effect.undefined,
223
+ (sandbox) =>
224
+ new ArchilNotReady({
225
+ resource: "Sandbox",
226
+ id: sid,
227
+ status: sandbox?.status ?? "deleted",
228
+ message: `Sandbox ${sandbox?.name ?? sid} is still ${sandbox?.status}`,
229
+ }),
230
+ ).pipe(Effect.asVoid);
231
+
232
+ /** Wait until a sandbox is gone after a delete. */
233
+ export const waitForSandboxGone = (sid: string) =>
234
+ pollUntil(
235
+ getSandbox(sid),
236
+ (sandbox) =>
237
+ sandbox === undefined || sandbox.status === "deleting"
238
+ ? Effect.succeed(true as const)
239
+ : Effect.undefined,
240
+ (sandbox) =>
241
+ new ArchilNotReady({
242
+ resource: "Sandbox",
243
+ id: sid,
244
+ status: sandbox?.status ?? "deleted",
245
+ message: `Sandbox ${sandbox?.name ?? sid} is still ${sandbox?.status}`,
246
+ }),
247
+ ).pipe(Effect.asVoid);
248
+
249
+ /** Wait until an image build finishes. */
250
+ export const waitForImage = (imageId: string) =>
251
+ pollUntil(
252
+ archil.getImage({ image_id: imageId }).pipe(Effect.map(({ data }) => data)),
253
+ (image) =>
254
+ image.status === "ready"
255
+ ? Effect.succeed(image)
256
+ : image.status === "failed"
257
+ ? Effect.fail(
258
+ new ArchilFailed({
259
+ resource: "Image",
260
+ id: imageId,
261
+ status: `failed${image.failure_reason ? ` (${image.failure_reason})` : ""}`,
262
+ message: `Image ${image.source} failed to build${image.failure_reason ? `: ${image.failure_reason}` : ""}`,
263
+ }),
264
+ )
265
+ : Effect.undefined,
266
+ (image) =>
267
+ new ArchilNotReady({
268
+ resource: "Image",
269
+ id: imageId,
270
+ status: image.status,
271
+ message: `Image ${image.source} is ${image.status}`,
272
+ }),
273
+ );
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Bundling and deploying the program of an `Archil.Sandbox` with `main`: a
3
+ * Node process run as an Archil sandbox service (`archil-sandbox services
4
+ * create`), which restarts when it crashes and starts whenever the sandbox
5
+ * boots.
6
+ */
7
+ import * as archil from "@distilled.cloud/archil";
8
+ import * as Process from "@distilled.cloud/archil/Process";
9
+ import * as Bundle from "alchemy/Bundle/Bundle";
10
+ import {
11
+ normalizeInstallTargets,
12
+ resolvePackageInstallIdentity,
13
+ type PackageInstall,
14
+ } from "alchemy/Bundle/InstalledPackages";
15
+ import { findCwdForBundle, resolveMainPath } from "alchemy/Bundle/TempRoot";
16
+ import {
17
+ contextRootOf,
18
+ extraFileDestination,
19
+ hashExtraFiles,
20
+ isContextRootDest,
21
+ posixRelUnder,
22
+ resolveExtraSource,
23
+ } from "alchemy/Util/extraFiles";
24
+ import { sha256, sha256Object } from "alchemy/Util/sha256";
25
+ import { zipFiles } from "alchemy/Util/zip";
26
+ import * as Data from "effect/Data";
27
+ import * as Effect from "effect/Effect";
28
+ import * as FileSystem from "effect/FileSystem";
29
+ import * as Path from "effect/Path";
30
+ import * as Redacted from "effect/Redacted";
31
+ import * as Schedule from "effect/Schedule";
32
+ import type * as rolldown from "rolldown";
33
+ import { waitForSandbox } from "./archil.ts";
34
+
35
+ /** A sandbox program failed to deploy or never started serving. */
36
+ export class ProgramDeployFailed extends Data.TaggedError("Archil.ProgramDeployFailed")<{
37
+ readonly program: string;
38
+ readonly message: string;
39
+ }> {}
40
+
41
+ /** Bundler options for a program, plus packages installed instead of bundled. */
42
+ export interface ProgramBuildOptions extends Bundle.BundleConfig {
43
+ /**
44
+ * Packages to `npm install` next to the program instead of bundling them
45
+ * (native modules, or frameworks that load files at run time).
46
+ */
47
+ readonly install?: PackageInstall;
48
+ }
49
+
50
+ /** A host directory (or file) uploaded with the program. */
51
+ export interface ProgramExtraFile {
52
+ readonly source: string;
53
+ /** Destination relative to the program's directory; `"."` is the root. */
54
+ readonly destination: string;
55
+ }
56
+
57
+ export interface HostedProgramProps {
58
+ readonly main: string;
59
+ readonly handler?: string;
60
+ readonly isExternal?: boolean;
61
+ readonly build?: ProgramBuildOptions;
62
+ readonly extraFiles?: ReadonlyArray<ProgramExtraFile>;
63
+ }
64
+
65
+ type ProgramFile = { readonly path: string; readonly content: Uint8Array };
66
+
67
+ export interface BundledProgram {
68
+ readonly files: ReadonlyArray<ProgramFile>;
69
+ readonly hash: string;
70
+ /** The entry `node` runs, relative to the program's directory. */
71
+ readonly entryRel: string;
72
+ /** Whether the program has a `package.json` to `npm install`. */
73
+ readonly install: boolean;
74
+ }
75
+
76
+ /** Entry for the Effect form: the package bootstrap plus the user's `main`. */
77
+ const effectEntry = (handler: string) => (importPath: string) =>
78
+ `
79
+ import { bootstrap } from "@alchemy.run/archil/Bootstrap";
80
+ import { ${handler} as entrypoint } from ${JSON.stringify(importPath)};
81
+
82
+ await bootstrap(entrypoint);
83
+ `;
84
+
85
+ /**
86
+ * Entry for the async form: serve the module's `fetch(request, env)` over
87
+ * `node:http`, with `process.env` as `env`. Nothing from alchemy or Effect
88
+ * is bundled.
89
+ */
90
+ const asyncEntry = (handler: string) => (importPath: string) =>
91
+ `
92
+ import * as mod from ${JSON.stringify(importPath)};
93
+ import http from "node:http";
94
+ import { Readable } from "node:stream";
95
+
96
+ const app = mod[${JSON.stringify(handler)}] ?? mod.default;
97
+ const port = Number(process.env.PORT ?? 3000);
98
+
99
+ http
100
+ .createServer(async (req, res) => {
101
+ try {
102
+ const url = new URL(req.url ?? "/", \`http://\${req.headers.host ?? "localhost"}\`);
103
+ const headers = new Headers();
104
+ for (const [key, value] of Object.entries(req.headers)) {
105
+ for (const v of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
106
+ headers.append(key, v);
107
+ }
108
+ }
109
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
110
+ const request = new Request(url, {
111
+ method: req.method,
112
+ headers,
113
+ body: hasBody ? Readable.toWeb(req) : undefined,
114
+ duplex: hasBody ? "half" : undefined,
115
+ });
116
+ const response = await app.fetch(request, process.env);
117
+ res.writeHead(response.status, Object.fromEntries(response.headers));
118
+ if (response.body) Readable.fromWeb(response.body).pipe(res);
119
+ else res.end();
120
+ } catch (error) {
121
+ console.error(error);
122
+ if (!res.headersSent) res.writeHead(500);
123
+ res.end("Internal Server Error");
124
+ }
125
+ })
126
+ .listen(port, "0.0.0.0");
127
+ `;
128
+
129
+ /**
130
+ * Paths left out of an upload. A tree uploaded at the program root keeps
131
+ * its `node_modules` (framework servers like Nuxt trace their dependencies
132
+ * there); other trees drop them.
133
+ */
134
+ const skipped = (relative: string, atRoot: boolean) =>
135
+ relative
136
+ .replaceAll("\\", "/")
137
+ .split("/")
138
+ .some((segment) => segment === ".git" || (!atRoot && segment === "node_modules"));
139
+
140
+ /** Read every extra file into program files at their destinations. */
141
+ const collectExtraFiles = Effect.fn(function* (extraFiles: ReadonlyArray<ProgramExtraFile>) {
142
+ const fs = yield* FileSystem.FileSystem;
143
+ const path = yield* Path.Path;
144
+ const files: ProgramFile[] = [];
145
+ for (const extra of extraFiles) {
146
+ const dest = extraFileDestination(extra.destination);
147
+ const source = resolveExtraSource(extra.source, path);
148
+ if (!(yield* fs.exists(source))) continue;
149
+ if ((yield* fs.stat(source)).type === "File") {
150
+ files.push({
151
+ path: isContextRootDest(dest) ? path.basename(source) : dest,
152
+ content: yield* fs.readFile(source),
153
+ });
154
+ continue;
155
+ }
156
+ const atRoot = isContextRootDest(dest);
157
+ for (const name of yield* fs.readDirectory(source, { recursive: true })) {
158
+ if (skipped(name, atRoot)) continue;
159
+ const full = path.join(source, name);
160
+ if ((yield* fs.stat(full)).type !== "File") continue;
161
+ const rel = name.replaceAll("\\", "/");
162
+ files.push({
163
+ path: isContextRootDest(dest) ? rel : `${dest}/${rel}`.replaceAll(/\/+/g, "/"),
164
+ content: yield* fs.readFile(full),
165
+ });
166
+ }
167
+ }
168
+ return files;
169
+ });
170
+
171
+ /** The `package.json` that installs `build.install`, pinned to the versions the project resolves. */
172
+ const installManifest = Effect.fn(function* (cwd: string, install: PackageInstall | undefined) {
173
+ const requested = yield* normalizeInstallTargets(install);
174
+ if (Object.keys(requested).length === 0) return undefined;
175
+ const identity = yield* resolvePackageInstallIdentity({ cwd, requested });
176
+ if (Object.keys(identity.resolved).length === 0) return undefined;
177
+ return `${JSON.stringify({ private: true, type: "module", dependencies: identity.resolved }, null, 2)}\n`;
178
+ });
179
+
180
+ /**
181
+ * Pack `main` into the files of a Node program.
182
+ *
183
+ * - The Effect form bundles `main` behind the package bootstrap.
184
+ * - The async form bundles `main` behind a `node:http` server for its
185
+ * `fetch(request, env)`.
186
+ * - With `extraFiles`, `main` is already a complete Node program (a
187
+ * framework's server entry): it is uploaded as built, with its tree.
188
+ */
189
+ export const bundleProgram = Effect.fn(function* (
190
+ props: HostedProgramProps,
191
+ virtualEntryPlugin: (content: (importPath: string) => string) => rolldown.Plugin,
192
+ ) {
193
+ const handler = props.handler ?? "default";
194
+ const realMain = yield* resolveMainPath(props.main);
195
+ const cwd = yield* findCwdForBundle(realMain);
196
+ const manifest = yield* installManifest(cwd, props.build?.install);
197
+ const manifestFiles: ProgramFile[] =
198
+ manifest === undefined
199
+ ? []
200
+ : [{ path: "package.json", content: new TextEncoder().encode(manifest) }];
201
+
202
+ if (props.isExternal && props.extraFiles !== undefined) {
203
+ const fs = yield* FileSystem.FileSystem;
204
+ const path = yield* Path.Path;
205
+ const extras = props.extraFiles.map((f) => ({ source: f.source, dest: f.destination }));
206
+ const root = contextRootOf(realMain, extras, path, (source) =>
207
+ resolveExtraSource(source, path),
208
+ );
209
+ const entryRel = posixRelUnder(root, realMain, path) ?? path.basename(realMain);
210
+ const entry = yield* fs.readFile(realMain);
211
+ const files: ProgramFile[] = [
212
+ ...(yield* collectExtraFiles(props.extraFiles)).filter(
213
+ (f) => !(f.path === "package.json" && manifest !== undefined),
214
+ ),
215
+ ...manifestFiles,
216
+ ];
217
+ if (!files.some((f) => f.path === entryRel)) files.push({ path: entryRel, content: entry });
218
+ const hash = yield* sha256Object({
219
+ entry: yield* sha256(entry),
220
+ extra: yield* hashExtraFiles(extras),
221
+ manifest: manifest ?? "",
222
+ entryRel,
223
+ });
224
+ return { files, hash, entryRel, install: manifest !== undefined } satisfies BundledProgram;
225
+ }
226
+
227
+ const entry = props.isExternal ? asyncEntry(handler) : effectEntry(handler);
228
+ const output = yield* Bundle.build(
229
+ {
230
+ ...props.build?.input,
231
+ input: realMain,
232
+ cwd,
233
+ platform: "node",
234
+ resolve: {
235
+ conditionNames: [...Bundle.NODE_CONDITION_NAMES],
236
+ ...props.build?.input?.resolve,
237
+ },
238
+ plugins: [props.build?.input?.plugins, virtualEntryPlugin(entry)],
239
+ },
240
+ {
241
+ ...props.build?.output,
242
+ format: "esm",
243
+ sourcemap: props.build?.output?.sourcemap ?? false,
244
+ minify: props.build?.output?.minify ?? false,
245
+ entryFileNames: "index.mjs",
246
+ strictExecutionOrder: true,
247
+ keepNames: true,
248
+ },
249
+ props.build,
250
+ );
251
+ const files: ProgramFile[] = [
252
+ ...output.files.map((file) => ({
253
+ path: file.path,
254
+ content:
255
+ typeof file.content === "string" ? new TextEncoder().encode(file.content) : file.content,
256
+ })),
257
+ ...manifestFiles,
258
+ ];
259
+ const hash = yield* sha256Object({ bundle: output.hash, manifest: manifest ?? "" });
260
+ return {
261
+ files,
262
+ hash,
263
+ entryRel: "index.mjs",
264
+ install: manifest !== undefined,
265
+ } satisfies BundledProgram;
266
+ });
267
+
268
+ /** Single-quote a value for `sh`. */
269
+ const shellQuote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
270
+
271
+ const envValue = (value: unknown): string =>
272
+ Redacted.isRedacted(value)
273
+ ? String(Redacted.value(value))
274
+ : typeof value === "string"
275
+ ? value
276
+ : JSON.stringify(value ?? null);
277
+
278
+ /** Boot a sandbox that is paused or stopped, and wait until it runs. */
279
+ export const ensureRunning = Effect.fn(function* (sid: string) {
280
+ const sandbox = yield* waitForSandbox(sid);
281
+ if (sandbox.status === "paused") {
282
+ yield* archil
283
+ .resumeSandbox({ sid })
284
+ .pipe(Effect.catchTag("SandboxStartConflict", () => Effect.void));
285
+ } else if (sandbox.status === "stopped" || sandbox.status === "exited") {
286
+ yield* archil
287
+ .startSandbox({ sid })
288
+ .pipe(Effect.catchTag("SandboxStartConflict", () => Effect.void));
289
+ }
290
+ const ready = yield* waitForSandbox(sid);
291
+ if (ready.status !== "running") {
292
+ return yield* new ProgramDeployFailed({
293
+ program: sid,
294
+ message: `Sandbox ${ready.name} is ${ready.status}, not running`,
295
+ });
296
+ }
297
+ });
298
+
299
+ /** Run a shell command in the sandbox and fail with its output on a non-zero exit. */
300
+ const sh = Effect.fn(function* (sid: string, program: string, command: string) {
301
+ const result = yield* Process.exec(sid, command);
302
+ if (result.exitCode !== 0) {
303
+ return yield* new ProgramDeployFailed({
304
+ program,
305
+ message: `\`${command.split("\n")[0]}\` exited ${result.exitCode ?? result.status}: ${(result.stderr || result.stdout).trim()}`,
306
+ });
307
+ }
308
+ return result.stdout;
309
+ });
310
+
311
+ /** The directory a program lives in. */
312
+ export const programDir = (name: string) => `/opt/alchemy/${name}`;
313
+
314
+ /**
315
+ * Upload the program, (re)define its service, and wait until it answers on
316
+ * `port` inside the sandbox.
317
+ */
318
+ export const deployProgram = Effect.fn(function* (input: {
319
+ readonly sid: string;
320
+ readonly name: string;
321
+ readonly port: number;
322
+ readonly program: BundledProgram;
323
+ readonly env: Record<string, unknown>;
324
+ }) {
325
+ const dir = programDir(input.name);
326
+ const staging = `${dir}.next`;
327
+ yield* ensureRunning(input.sid);
328
+ yield* sh(
329
+ input.sid,
330
+ input.name,
331
+ `rm -rf ${shellQuote(staging)} && mkdir -p ${shellQuote(staging)}`,
332
+ );
333
+ // One archive, one upload: a framework build can have thousands of files.
334
+ const archive = yield* zipFiles(input.program.files);
335
+ yield* Effect.logInfo(
336
+ `Archil.Sandbox program: uploading ${input.program.files.length} files (${Math.round(archive.byteLength / 1024)} KiB)`,
337
+ );
338
+ yield* Process.uploadFile(input.sid, `${staging}.zip`, archive);
339
+ yield* sh(
340
+ input.sid,
341
+ input.name,
342
+ `python3 -m zipfile -e ${shellQuote(`${staging}.zip`)} ${shellQuote(staging)} && rm -f ${shellQuote(`${staging}.zip`)}`,
343
+ );
344
+ if (input.program.install) {
345
+ yield* Effect.logInfo(`Archil.Sandbox program: npm install`);
346
+ yield* sh(
347
+ input.sid,
348
+ input.name,
349
+ `cd ${shellQuote(staging)} && npm install --omit=dev --no-fund --no-audit --loglevel=error`,
350
+ );
351
+ }
352
+ const envFlags = Object.entries({ ...input.env, PORT: String(input.port) })
353
+ .sort(([a], [b]) => a.localeCompare(b))
354
+ .map(([key, value]) => `--env ${shellQuote(`${key}=${envValue(value)}`)}`)
355
+ .join(" ");
356
+ yield* sh(
357
+ input.sid,
358
+ input.name,
359
+ [
360
+ `archil-sandbox services delete ${shellQuote(input.name)} >/dev/null 2>&1 || true`,
361
+ `rm -rf ${shellQuote(dir)} && mv ${shellQuote(staging)} ${shellQuote(dir)}`,
362
+ `archil-sandbox services create ${shellQuote(input.name)} --tcp-port ${input.port} --dir ${shellQuote(dir)} ${envFlags} -- node ${shellQuote(input.program.entryRel)} >/dev/null`,
363
+ ].join(" && "),
364
+ );
365
+ yield* Effect.logInfo(`Archil.Sandbox program: started, waiting for port ${input.port}`);
366
+ // Wait until the process answers HTTP on its port.
367
+ const probe = `curl -s -o /dev/null -m 2 -w '%{http_code}' http://127.0.0.1:${input.port}/ || true`;
368
+ const code = yield* Process.exec(input.sid, probe).pipe(
369
+ Effect.map((r) => r.stdout.trim()),
370
+ Effect.repeat({
371
+ schedule: Schedule.spaced("1 second"),
372
+ until: (status) => status !== "" && status !== "000",
373
+ times: 30,
374
+ }),
375
+ );
376
+ if (code === "" || code === "000") {
377
+ const logs = yield* Process.exec(
378
+ input.sid,
379
+ `archil-sandbox services logs ${shellQuote(input.name)} 2>&1 | tail -n 40`,
380
+ ).pipe(
381
+ Effect.map((r) => r.stdout),
382
+ Effect.orElseSucceed(() => ""),
383
+ );
384
+ return yield* new ProgramDeployFailed({
385
+ program: input.name,
386
+ message: `The program never answered on port ${input.port}.\n${logs}`,
387
+ });
388
+ }
389
+ });
390
+
391
+ /** Stop the program and remove its files. Removing a missing one succeeds. */
392
+ export const removeProgram = Effect.fn(function* (sid: string, name: string) {
393
+ const sandbox = yield* waitForSandbox(sid).pipe(
394
+ Effect.catchTag("NotFound", () => Effect.undefined),
395
+ );
396
+ if (sandbox === undefined || sandbox.status === "deleted") return;
397
+ yield* ensureRunning(sid);
398
+ yield* Process.exec(
399
+ sid,
400
+ `archil-sandbox services delete ${shellQuote(name)} >/dev/null 2>&1; rm -rf ${shellQuote(programDir(name))}`,
401
+ );
402
+ });