@effect-uai/deno 0.5.2
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.
- package/LICENSE +21 -0
- package/dist/DenoSandbox.d.mts +76 -0
- package/dist/DenoSandbox.d.mts.map +1 -0
- package/dist/DenoSandbox.mjs +438 -0
- package/dist/DenoSandbox.mjs.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/package.json +61 -0
- package/src/DenoSandbox.ts +939 -0
- package/src/index.ts +1 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
import * as Memory from "@effect-uai/core/Memory"
|
|
2
|
+
import {
|
|
3
|
+
ExecEvent as CoreExecEvent,
|
|
4
|
+
Sandbox as CoreSandbox,
|
|
5
|
+
SandboxHostnameAllowlist,
|
|
6
|
+
SandboxPortExposure,
|
|
7
|
+
SandboxSecretInjection,
|
|
8
|
+
SandboxSnapshots,
|
|
9
|
+
SandboxVolumes,
|
|
10
|
+
type BoundSecret,
|
|
11
|
+
type CommonCreateRequest,
|
|
12
|
+
type CommonExecRequest,
|
|
13
|
+
type CommonSpawnRequest,
|
|
14
|
+
type ExecResult,
|
|
15
|
+
type ImageRef,
|
|
16
|
+
type NetworkPolicy,
|
|
17
|
+
type ProcessHandle,
|
|
18
|
+
SandboxId,
|
|
19
|
+
type SandboxInstance,
|
|
20
|
+
type SandboxRef,
|
|
21
|
+
type SandboxService,
|
|
22
|
+
SnapshotId,
|
|
23
|
+
VolumeId,
|
|
24
|
+
} from "@effect-uai/core/Sandbox"
|
|
25
|
+
import * as SandboxError from "@effect-uai/core/SandboxError"
|
|
26
|
+
import {
|
|
27
|
+
Array as Arr,
|
|
28
|
+
Clock,
|
|
29
|
+
Context,
|
|
30
|
+
Duration,
|
|
31
|
+
Effect,
|
|
32
|
+
Layer,
|
|
33
|
+
Match,
|
|
34
|
+
Option,
|
|
35
|
+
Random,
|
|
36
|
+
Redacted,
|
|
37
|
+
Schedule,
|
|
38
|
+
Scope,
|
|
39
|
+
Stream,
|
|
40
|
+
} from "effect"
|
|
41
|
+
import {
|
|
42
|
+
ApiError,
|
|
43
|
+
Client,
|
|
44
|
+
ConnectionClosedError,
|
|
45
|
+
ConnectionEstablishmentError,
|
|
46
|
+
InvalidMemoryError,
|
|
47
|
+
InvalidTimeoutError,
|
|
48
|
+
InvalidTokenError,
|
|
49
|
+
MissingTokenError,
|
|
50
|
+
Sandbox as DenoSdkSandbox,
|
|
51
|
+
SandboxCommandError,
|
|
52
|
+
SandboxKillError,
|
|
53
|
+
type ChildProcess as DenoChildProcess,
|
|
54
|
+
type SandboxOptions,
|
|
55
|
+
type SecretConfig,
|
|
56
|
+
} from "@deno/sandbox"
|
|
57
|
+
|
|
58
|
+
const PROVIDER = "deno"
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Provider-narrowed request types.
|
|
62
|
+
//
|
|
63
|
+
// Deno's `secrets: Record<name, SecretConfig>` shape has no per-secret
|
|
64
|
+
// custom header — substitution is always "Authorization: Bearer <value>".
|
|
65
|
+
// We omit `header` on the typed surface (row B) and reject at the upcast
|
|
66
|
+
// adapter (row D) if a caller routes through the generic `Sandbox.create`.
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export type DenoSandboxBoundSecret = Omit<BoundSecret, "header">
|
|
70
|
+
|
|
71
|
+
export type DenoRegion = "ord" | "ams"
|
|
72
|
+
|
|
73
|
+
export type DenoSandboxCreateRequest = Omit<CommonCreateRequest, "secrets"> & {
|
|
74
|
+
readonly secrets?: ReadonlyArray<DenoSandboxBoundSecret>
|
|
75
|
+
readonly region?: DenoRegion
|
|
76
|
+
/**
|
|
77
|
+
* Memory size for the sandbox. Accepts a byte count, a {@link Memory.Memory},
|
|
78
|
+
* or a human string like `"1280 MiB"` / `"1 GiB"`. Deno's accepted
|
|
79
|
+
* range at GA is 768 MiB – 4 GiB (default 1280 MiB).
|
|
80
|
+
*/
|
|
81
|
+
readonly memory?: Memory.Input
|
|
82
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
83
|
+
/** Auto-expose this port at boot; the live sandbox's `url` reaches it. */
|
|
84
|
+
readonly port?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type DenoSandboxConfig = {
|
|
88
|
+
/** Overrides `DENO_DEPLOY_TOKEN`. */
|
|
89
|
+
readonly token?: Redacted.Redacted<string>
|
|
90
|
+
/** Overrides `DENO_DEPLOY_ORG`. Required for personal `ddp_` tokens. */
|
|
91
|
+
readonly org?: string
|
|
92
|
+
/** Overrides `DENO_DEPLOY_ENDPOINT`. */
|
|
93
|
+
readonly apiEndpoint?: string
|
|
94
|
+
/** Default region when create requests omit one. */
|
|
95
|
+
readonly defaultRegion?: DenoRegion
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Deno-narrowed service. `create` accepts the narrowed
|
|
100
|
+
* {@link DenoSandboxCreateRequest}; every other method is identical to
|
|
101
|
+
* the generic {@link SandboxService} except for the per-provider
|
|
102
|
+
* `snapshotVolume` escape hatch — Deno snapshots are derived from
|
|
103
|
+
* volumes, not from live sandboxes, so the generic
|
|
104
|
+
* `Sandbox.snapshots.create(fromInstance)` doesn't fit and fails with
|
|
105
|
+
* `SandboxUnsupported`.
|
|
106
|
+
*/
|
|
107
|
+
export type DenoSandboxService = Omit<SandboxService, "create"> & {
|
|
108
|
+
readonly create: (
|
|
109
|
+
request: DenoSandboxCreateRequest,
|
|
110
|
+
) => Effect.Effect<SandboxInstance, SandboxError.SandboxError, Scope.Scope>
|
|
111
|
+
/**
|
|
112
|
+
* Snapshot an existing volume by id/slug. Deno's snapshot model is
|
|
113
|
+
* volume-derived, so the generic `Sandbox.snapshots.create` doesn't
|
|
114
|
+
* apply — use this escape hatch instead. The volume must not be
|
|
115
|
+
* attached to any running sandbox at the time of snapshot.
|
|
116
|
+
*/
|
|
117
|
+
readonly snapshotVolume: (
|
|
118
|
+
volumeId: VolumeId,
|
|
119
|
+
slug?: string,
|
|
120
|
+
) => Effect.Effect<SnapshotId, SandboxError.SandboxError>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class DenoSandbox extends Context.Service<DenoSandbox, DenoSandboxService>()(
|
|
124
|
+
"@betalyra/effect-uai/providers/deno/DenoSandbox",
|
|
125
|
+
) {}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Effect-native primitives
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** Random base-36 token, ~8 chars. */
|
|
132
|
+
const randomToken = Random.next.pipe(Effect.map((r) => r.toString(36).slice(2, 10)))
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Error mapping
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const reasonOf = (e: unknown) => (e instanceof Error ? { reason: e.message } : {})
|
|
139
|
+
|
|
140
|
+
const isAuthError = (e: unknown): boolean =>
|
|
141
|
+
e instanceof MissingTokenError ||
|
|
142
|
+
e instanceof InvalidTokenError ||
|
|
143
|
+
(e instanceof ApiError && (e.status === 401 || e.status === 403)) ||
|
|
144
|
+
(e instanceof ConnectionEstablishmentError && (e.status === 401 || e.status === 403))
|
|
145
|
+
|
|
146
|
+
const isQuotaError = (e: unknown): boolean =>
|
|
147
|
+
(e instanceof ApiError && e.status === 429) ||
|
|
148
|
+
(e instanceof ConnectionEstablishmentError && e.status === 429)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* "Sandbox is no longer reachable" — covers both the 404 (never existed)
|
|
152
|
+
* and the 400 + `SANDBOX_ALREADY_TERMINATED` case the API returns when
|
|
153
|
+
* you `connect` to a sandbox after its `kill`. Both map to
|
|
154
|
+
* `SandboxNotFound` so `destroy` and friends stay idempotent.
|
|
155
|
+
*/
|
|
156
|
+
const isNotFoundError = (e: unknown): boolean =>
|
|
157
|
+
(e instanceof ApiError && (e.status === 404 || e.code === "SANDBOX_ALREADY_TERMINATED")) ||
|
|
158
|
+
(e instanceof ConnectionEstablishmentError &&
|
|
159
|
+
(e.status === 404 || e.code === "SANDBOX_ALREADY_TERMINATED"))
|
|
160
|
+
|
|
161
|
+
const mapCreateError = (e: unknown): SandboxError.SandboxError =>
|
|
162
|
+
isAuthError(e)
|
|
163
|
+
? new SandboxError.SandboxAuthFailed({ provider: PROVIDER, subtype: "auth", raw: e })
|
|
164
|
+
: isQuotaError(e)
|
|
165
|
+
? new SandboxError.SandboxQuotaExceeded({
|
|
166
|
+
provider: PROVIDER,
|
|
167
|
+
raw: e,
|
|
168
|
+
...reasonOf(e),
|
|
169
|
+
})
|
|
170
|
+
: e instanceof InvalidTimeoutError || e instanceof InvalidMemoryError
|
|
171
|
+
? new SandboxError.SandboxInvalidRequest({
|
|
172
|
+
provider: PROVIDER,
|
|
173
|
+
raw: e,
|
|
174
|
+
...reasonOf(e),
|
|
175
|
+
})
|
|
176
|
+
: new SandboxError.SandboxCreateFailed({
|
|
177
|
+
provider: PROVIDER,
|
|
178
|
+
raw: e,
|
|
179
|
+
...reasonOf(e),
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const mapLookupError =
|
|
183
|
+
(id: string) =>
|
|
184
|
+
(e: unknown): SandboxError.SandboxError =>
|
|
185
|
+
isNotFoundError(e)
|
|
186
|
+
? new SandboxError.SandboxNotFound({ provider: PROVIDER, id })
|
|
187
|
+
: mapCreateError(e)
|
|
188
|
+
|
|
189
|
+
const mapExecError = (e: unknown): SandboxError.SandboxError =>
|
|
190
|
+
e instanceof SandboxCommandError
|
|
191
|
+
? new SandboxError.SandboxExecFailed({
|
|
192
|
+
provider: PROVIDER,
|
|
193
|
+
raw: e,
|
|
194
|
+
reason: e.message,
|
|
195
|
+
})
|
|
196
|
+
: e instanceof ConnectionClosedError
|
|
197
|
+
? new SandboxError.SandboxExecFailed({
|
|
198
|
+
provider: PROVIDER,
|
|
199
|
+
raw: e,
|
|
200
|
+
reason: e.reason ?? "connection closed",
|
|
201
|
+
})
|
|
202
|
+
: new SandboxError.SandboxExecFailed({
|
|
203
|
+
provider: PROVIDER,
|
|
204
|
+
raw: e,
|
|
205
|
+
...reasonOf(e),
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const mapFsError = (e: unknown): SandboxError.SandboxError =>
|
|
209
|
+
new SandboxError.SandboxExecFailed({
|
|
210
|
+
provider: PROVIDER,
|
|
211
|
+
raw: e,
|
|
212
|
+
...reasonOf(e),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Transient WebSocket handshake failures.
|
|
217
|
+
//
|
|
218
|
+
// Empirically (SDK 0.13.2): `sdk.spawn` opens a per-process WebSocket
|
|
219
|
+
// to the sandbox-side relay. The Deploy edge intermittently returns
|
|
220
|
+
// `500 SANDBOX_WEBSOCKET_HANDSHAKE_ERROR` on this handshake; the next
|
|
221
|
+
// attempt against the same sandbox typically succeeds. Retry narrowly
|
|
222
|
+
// on that specific code rather than blanketing all 5xx — most are not
|
|
223
|
+
// transient.
|
|
224
|
+
//
|
|
225
|
+
// Budget: 3 attempts ≈ 500+1000+2000 = 3.5s on top of the original
|
|
226
|
+
// call, well under the 120s test timeout.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
const isTransientWsHandshake = (e: SandboxError.SandboxError) =>
|
|
230
|
+
(e._tag === "SandboxExecFailed" || e._tag === "SandboxCreateFailed") &&
|
|
231
|
+
e.raw instanceof ConnectionEstablishmentError &&
|
|
232
|
+
e.raw.code === "SANDBOX_WEBSOCKET_HANDSHAKE_ERROR"
|
|
233
|
+
|
|
234
|
+
const wsHandshakeSchedule = Schedule.exponential("500 millis")
|
|
235
|
+
const WS_HANDSHAKE_RETRIES = 3
|
|
236
|
+
|
|
237
|
+
const retryOnTransientWs = <A, R>(
|
|
238
|
+
effect: Effect.Effect<A, SandboxError.SandboxError, R>,
|
|
239
|
+
): Effect.Effect<A, SandboxError.SandboxError, R> =>
|
|
240
|
+
Effect.retry(effect, {
|
|
241
|
+
schedule: wsHandshakeSchedule,
|
|
242
|
+
times: WS_HANDSHAKE_RETRIES,
|
|
243
|
+
while: isTransientWsHandshake,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Builder pipeline — a `Step` mutates `SandboxOptions`. Deno's create
|
|
248
|
+
// API is a plain options object (no builder fluent API), so each Step
|
|
249
|
+
// returns a partial overlay; the create flow folds them into the base.
|
|
250
|
+
// Keeping the same shape as microsandbox's `Step` pattern means optional
|
|
251
|
+
// fields read uniformly.
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
type OptionsPatch = Partial<SandboxOptions>
|
|
255
|
+
type Step = (patch: OptionsPatch) => OptionsPatch
|
|
256
|
+
|
|
257
|
+
const noop: Step = (p) => p
|
|
258
|
+
|
|
259
|
+
const when = <A>(value: A | undefined, step: (a: A) => Step): Step =>
|
|
260
|
+
value === undefined ? noop : step(value)
|
|
261
|
+
|
|
262
|
+
const set =
|
|
263
|
+
<K extends keyof SandboxOptions>(key: K, value: SandboxOptions[K]): Step =>
|
|
264
|
+
(p) => ({ ...p, [key]: value })
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Duration → Deno timeout format ("30s"). Deno's `create.timeout`
|
|
268
|
+
// accepts `"session" | "${number}s" | "${number}m"`; we always emit
|
|
269
|
+
// `<seconds>s` since seconds is the most precise unit honored.
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
const toDenoSeconds = (d: Duration.Input): `${number}s` => {
|
|
273
|
+
const secs = Math.max(1, Math.ceil(Duration.toMillis(Duration.fromInputUnsafe(d)) / 1000))
|
|
274
|
+
return `${secs}s`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// ImageRef → Step. Default omits `root`; Snapshot sets it; Registry /
|
|
279
|
+
// Dockerfile are unsupported and fail at decode.
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
const imageStep = (image: ImageRef | undefined): Effect.Effect<Step, SandboxError.SandboxError> => {
|
|
283
|
+
if (image === undefined) return Effect.succeed(noop)
|
|
284
|
+
return Match.value(image).pipe(
|
|
285
|
+
Match.tag(
|
|
286
|
+
"Default",
|
|
287
|
+
(): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(noop),
|
|
288
|
+
),
|
|
289
|
+
Match.tag(
|
|
290
|
+
"Snapshot",
|
|
291
|
+
({ id }): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(set("root", id)),
|
|
292
|
+
),
|
|
293
|
+
Match.tag(
|
|
294
|
+
"Registry",
|
|
295
|
+
(): Effect.Effect<Step, SandboxError.SandboxError> =>
|
|
296
|
+
Effect.fail(
|
|
297
|
+
new SandboxError.SandboxUnsupported({
|
|
298
|
+
provider: PROVIDER,
|
|
299
|
+
capability: "image.registry",
|
|
300
|
+
reason: "deno sandboxes do not accept OCI registry refs — boot from a snapshot instead",
|
|
301
|
+
}),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
Match.tag(
|
|
305
|
+
"Dockerfile",
|
|
306
|
+
(): Effect.Effect<Step, SandboxError.SandboxError> =>
|
|
307
|
+
Effect.fail(
|
|
308
|
+
new SandboxError.SandboxUnsupported({
|
|
309
|
+
provider: PROVIDER,
|
|
310
|
+
capability: "image.dockerfile",
|
|
311
|
+
reason:
|
|
312
|
+
"deno sandboxes do not accept Dockerfiles — install software into a bootable volume then snapshot it",
|
|
313
|
+
}),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
Match.exhaustive,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// NetworkPolicy → Step. Deno accepts hostnames + literal IPs but no
|
|
322
|
+
// CIDR ranges; reject `cidrs` rather than silently dropping. `Blocked`
|
|
323
|
+
// encodes as empty allowlist; `Open` omits the field.
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
const networkStep = (policy: NetworkPolicy): Effect.Effect<Step, SandboxError.SandboxError> =>
|
|
327
|
+
Match.value(policy).pipe(
|
|
328
|
+
Match.tag("Open", (): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(noop)),
|
|
329
|
+
Match.tag(
|
|
330
|
+
"Blocked",
|
|
331
|
+
(): Effect.Effect<Step, SandboxError.SandboxError> => Effect.succeed(set("allowNet", [])),
|
|
332
|
+
),
|
|
333
|
+
Match.tag(
|
|
334
|
+
"Allowlist",
|
|
335
|
+
({ hosts, cidrs }): Effect.Effect<Step, SandboxError.SandboxError> =>
|
|
336
|
+
cidrs !== undefined && cidrs.length > 0
|
|
337
|
+
? Effect.fail(
|
|
338
|
+
new SandboxError.SandboxUnsupported({
|
|
339
|
+
provider: PROVIDER,
|
|
340
|
+
capability: "network.cidrs",
|
|
341
|
+
reason:
|
|
342
|
+
"deno's allowNet accepts hostnames or literal IPs, not CIDR ranges — use hosts instead",
|
|
343
|
+
}),
|
|
344
|
+
)
|
|
345
|
+
: Effect.succeed(set("allowNet", [...(hosts ?? [])])),
|
|
346
|
+
),
|
|
347
|
+
Match.exhaustive,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Volumes → Step. Deno mounts read-write only; reject `readonly: true`.
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
const volumesStep = (
|
|
355
|
+
volumes: CommonCreateRequest["volumes"],
|
|
356
|
+
): Effect.Effect<Step, SandboxError.SandboxError> => {
|
|
357
|
+
if (volumes === undefined || volumes.length === 0) return Effect.succeed(noop)
|
|
358
|
+
if (volumes.some((v) => v.readonly === true)) {
|
|
359
|
+
return Effect.fail(
|
|
360
|
+
new SandboxError.SandboxUnsupported({
|
|
361
|
+
provider: PROVIDER,
|
|
362
|
+
capability: "volumes.readonly",
|
|
363
|
+
reason: "deno mounts volumes read-write — read-only mounts are not supported",
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
const map = Arr.reduce(volumes, {} as Record<string, string>, (acc, v) => {
|
|
368
|
+
acc[v.mountPath] = v.id
|
|
369
|
+
return acc
|
|
370
|
+
})
|
|
371
|
+
return Effect.succeed(set("volumes", map))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Secrets → Step. Stays sync — no decode-time failure modes (custom
|
|
376
|
+
// header is rejected upstream in the upcast).
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
const secretsStep = (secrets: ReadonlyArray<DenoSandboxBoundSecret>): Step => {
|
|
380
|
+
const record = Arr.reduce(secrets, {} as Record<string, SecretConfig>, (acc, s) => {
|
|
381
|
+
acc[s.name] = { hosts: [...s.hosts], value: Redacted.value(s.value) }
|
|
382
|
+
return acc
|
|
383
|
+
})
|
|
384
|
+
return set("secrets", record)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Base client options used for both client construction and per-call
|
|
389
|
+
// overrides. `token` is a Redacted — we unwrap at the SDK boundary.
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
const baseClientOptions = (config: DenoSandboxConfig): SandboxOptions => ({
|
|
393
|
+
...(config.token === undefined ? {} : { token: Redacted.value(config.token) }),
|
|
394
|
+
...(config.org === undefined ? {} : { org: config.org }),
|
|
395
|
+
...(config.apiEndpoint === undefined ? {} : { apiEndpoint: config.apiEndpoint }),
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// argv splitter. Deno's `spawn(command, { args })` is direct argv only —
|
|
400
|
+
// for `cmd: string` (shell semantics) we route through `bash -c`.
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
const argv = (
|
|
404
|
+
cmd: CommonExecRequest["cmd"],
|
|
405
|
+
): Effect.Effect<
|
|
406
|
+
{ readonly command: string; readonly args: ReadonlyArray<string> },
|
|
407
|
+
SandboxError.SandboxError
|
|
408
|
+
> =>
|
|
409
|
+
typeof cmd === "string"
|
|
410
|
+
? Effect.succeed({ command: "bash", args: ["-c", cmd] })
|
|
411
|
+
: Option.match(Arr.head(cmd), {
|
|
412
|
+
onNone: () =>
|
|
413
|
+
Effect.fail(
|
|
414
|
+
new SandboxError.SandboxInvalidRequest({
|
|
415
|
+
provider: PROVIDER,
|
|
416
|
+
param: "cmd",
|
|
417
|
+
reason: "argv array must be non-empty",
|
|
418
|
+
}),
|
|
419
|
+
),
|
|
420
|
+
onSome: (head) => Effect.succeed({ command: head, args: Arr.drop(cmd, 1) }),
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
// AbortController scoped to the caller. On scope close we abort, which
|
|
425
|
+
// signals the SDK to terminate the in-flight call. This is the kill-
|
|
426
|
+
// switch the SDK responds to.
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
const acquireAbortController: Effect.Effect<AbortController, never, Scope.Scope> =
|
|
430
|
+
Effect.acquireRelease(
|
|
431
|
+
Effect.sync(() => new AbortController()),
|
|
432
|
+
(ac) => Effect.sync(() => ac.abort()),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// stdin helpers
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
const stdinIsStream = (
|
|
440
|
+
stdin: CommonSpawnRequest["stdin"],
|
|
441
|
+
): stdin is Stream.Stream<Uint8Array, never, never> =>
|
|
442
|
+
stdin !== undefined && typeof stdin !== "string" && !(stdin instanceof Uint8Array)
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Copy into a fresh `Uint8Array<ArrayBuffer>`. The Deno SDK strictly
|
|
446
|
+
* types its bytes argument as `Uint8Array<ArrayBuffer>` (not the
|
|
447
|
+
* `ArrayBufferLike` upper bound), so `TextEncoder`-produced bytes and
|
|
448
|
+
* caller-supplied Uint8Arrays need to be copied to a guaranteed-
|
|
449
|
+
* `ArrayBuffer`-backed buffer to satisfy the type.
|
|
450
|
+
*/
|
|
451
|
+
const toBytes = (input: Uint8Array | string): Uint8Array<ArrayBuffer> => {
|
|
452
|
+
const src = typeof input === "string" ? new TextEncoder().encode(input) : input
|
|
453
|
+
const out = new Uint8Array(new ArrayBuffer(src.byteLength))
|
|
454
|
+
out.set(src)
|
|
455
|
+
return out
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Acquire the child's stdin writer, run `use(writer)`, then close the
|
|
460
|
+
* writer on release (regardless of success / failure / interrupt).
|
|
461
|
+
* Returns `Effect.void` if the child wasn't spawned with `stdin: "piped"`.
|
|
462
|
+
*/
|
|
463
|
+
const withStdinWriter = <A, E, R>(
|
|
464
|
+
child: DenoChildProcess,
|
|
465
|
+
use: (writer: WritableStreamDefaultWriter<Uint8Array>) => Effect.Effect<A, E, R>,
|
|
466
|
+
): Effect.Effect<void | A, E | SandboxError.SandboxError, R> => {
|
|
467
|
+
const stdin = child.stdin
|
|
468
|
+
if (stdin === null) return Effect.void
|
|
469
|
+
return Effect.acquireUseRelease(
|
|
470
|
+
Effect.sync(() => stdin.getWriter()),
|
|
471
|
+
use,
|
|
472
|
+
(writer) => Effect.promise(() => writer.close().catch(() => undefined)).pipe(Effect.ignore),
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const writeStdinBytes = (
|
|
477
|
+
child: DenoChildProcess,
|
|
478
|
+
bytes: Uint8Array,
|
|
479
|
+
): Effect.Effect<void, SandboxError.SandboxError> =>
|
|
480
|
+
withStdinWriter(child, (writer) =>
|
|
481
|
+
Effect.tryPromise({ try: () => writer.write(bytes), catch: mapExecError }),
|
|
482
|
+
).pipe(Effect.asVoid)
|
|
483
|
+
|
|
484
|
+
const feedStreamStdin = (
|
|
485
|
+
child: DenoChildProcess,
|
|
486
|
+
source: Stream.Stream<Uint8Array, never, never>,
|
|
487
|
+
): Effect.Effect<void, SandboxError.SandboxError> =>
|
|
488
|
+
withStdinWriter(child, (writer) =>
|
|
489
|
+
Stream.runForEach(source, (chunk) =>
|
|
490
|
+
Effect.tryPromise({ try: () => writer.write(chunk), catch: mapExecError }),
|
|
491
|
+
),
|
|
492
|
+
).pipe(Effect.asVoid)
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// ReadableStream<Uint8Array> → Stream<Uint8Array>.
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
const streamFromReadable = (
|
|
499
|
+
readable: ReadableStream<Uint8Array>,
|
|
500
|
+
): Stream.Stream<Uint8Array, SandboxError.SandboxError> =>
|
|
501
|
+
Stream.fromReadableStream({
|
|
502
|
+
evaluate: () => readable,
|
|
503
|
+
onError: mapExecError,
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Merge stdout + stderr into a tagged ExecEvent stream terminated by a
|
|
508
|
+
// single Complete event derived from `child.status`.
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
const eventStream = (
|
|
512
|
+
child: DenoChildProcess,
|
|
513
|
+
startedAt: number,
|
|
514
|
+
): Stream.Stream<CoreExecEvent, SandboxError.SandboxError> => {
|
|
515
|
+
const stdout: Stream.Stream<CoreExecEvent, SandboxError.SandboxError> =
|
|
516
|
+
child.stdout === null
|
|
517
|
+
? Stream.empty
|
|
518
|
+
: Stream.map(streamFromReadable(child.stdout), (chunk) => CoreExecEvent.Stdout({ chunk }))
|
|
519
|
+
const stderr: Stream.Stream<CoreExecEvent, SandboxError.SandboxError> =
|
|
520
|
+
child.stderr === null
|
|
521
|
+
? Stream.empty
|
|
522
|
+
: Stream.map(streamFromReadable(child.stderr), (chunk) => CoreExecEvent.Stderr({ chunk }))
|
|
523
|
+
const complete: Stream.Stream<CoreExecEvent, SandboxError.SandboxError> = Stream.unwrap(
|
|
524
|
+
Effect.gen(function* () {
|
|
525
|
+
const status = yield* Effect.tryPromise({ try: () => child.status, catch: mapExecError })
|
|
526
|
+
const now = yield* Clock.currentTimeMillis
|
|
527
|
+
return Stream.succeed(
|
|
528
|
+
CoreExecEvent.Complete({ exitCode: status.code, durationMs: now - startedAt }),
|
|
529
|
+
)
|
|
530
|
+
}),
|
|
531
|
+
)
|
|
532
|
+
return Stream.concat(Stream.merge(stdout, stderr), complete)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Per-exec timeout. Races against `Effect.sleep` so the parent scope
|
|
537
|
+
// finalizers (which kill the child via `acquireRelease`) fire on breach.
|
|
538
|
+
// We don't lean on `Effect.timeout` because it returns `Option`-shaped
|
|
539
|
+
// fallbacks in v4; the race-based shape lets us keep the SandboxError
|
|
540
|
+
// channel directly.
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
const withExecTimeout = <A, R>(
|
|
544
|
+
timeout: Duration.Input | undefined,
|
|
545
|
+
effect: Effect.Effect<A, SandboxError.SandboxError, R>,
|
|
546
|
+
): Effect.Effect<A, SandboxError.SandboxError, R> =>
|
|
547
|
+
timeout === undefined
|
|
548
|
+
? effect
|
|
549
|
+
: Effect.race(
|
|
550
|
+
effect,
|
|
551
|
+
Effect.sleep(timeout).pipe(
|
|
552
|
+
Effect.andThen(
|
|
553
|
+
Effect.fail(
|
|
554
|
+
new SandboxError.SandboxTimeout({
|
|
555
|
+
provider: PROVIDER,
|
|
556
|
+
operation: "exec",
|
|
557
|
+
budget: Duration.fromInputUnsafe(timeout),
|
|
558
|
+
}),
|
|
559
|
+
),
|
|
560
|
+
),
|
|
561
|
+
),
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Live SandboxInstance adapter. The `WeakMap<SandboxInstance, sdk>`
|
|
566
|
+
// passed in is the back-reference used by the port-exposure sub-API
|
|
567
|
+
// to recover the SDK handle from an opaque `SandboxInstance`.
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
const adaptInstance = (
|
|
571
|
+
sdk: DenoSdkSandbox,
|
|
572
|
+
portRegistry: WeakMap<SandboxInstance, DenoSdkSandbox>,
|
|
573
|
+
): SandboxInstance => {
|
|
574
|
+
const id = SandboxId(sdk.id)
|
|
575
|
+
|
|
576
|
+
const openProcess = (request: CommonSpawnRequest) =>
|
|
577
|
+
Effect.gen(function* () {
|
|
578
|
+
const { command, args } = yield* argv(request.cmd)
|
|
579
|
+
const ac = yield* acquireAbortController
|
|
580
|
+
const stdinMode: "piped" | "null" = request.stdin === undefined ? "null" : "piped"
|
|
581
|
+
const startedAt = yield* Clock.currentTimeMillis
|
|
582
|
+
const child = yield* Effect.acquireRelease(
|
|
583
|
+
retryOnTransientWs(
|
|
584
|
+
Effect.tryPromise({
|
|
585
|
+
try: () =>
|
|
586
|
+
sdk.spawn(command, {
|
|
587
|
+
args: [...args],
|
|
588
|
+
...(request.cwd === undefined ? {} : { cwd: request.cwd }),
|
|
589
|
+
...(request.env === undefined ? {} : { env: { ...request.env } }),
|
|
590
|
+
stdin: stdinMode,
|
|
591
|
+
stdout: "piped",
|
|
592
|
+
stderr: "piped",
|
|
593
|
+
signal: ac.signal,
|
|
594
|
+
}),
|
|
595
|
+
catch: mapExecError,
|
|
596
|
+
}),
|
|
597
|
+
),
|
|
598
|
+
(c) => Effect.promise(() => c.kill("SIGKILL").catch(() => undefined)).pipe(Effect.ignore),
|
|
599
|
+
)
|
|
600
|
+
if (request.stdin !== undefined) {
|
|
601
|
+
if (stdinIsStream(request.stdin)) {
|
|
602
|
+
yield* Effect.forkScoped(feedStreamStdin(child, request.stdin))
|
|
603
|
+
} else {
|
|
604
|
+
yield* writeStdinBytes(child, toBytes(request.stdin))
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return { child, startedAt }
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
const instance: SandboxInstance = {
|
|
611
|
+
id,
|
|
612
|
+
|
|
613
|
+
exec: (request) =>
|
|
614
|
+
withExecTimeout(
|
|
615
|
+
request.timeout,
|
|
616
|
+
Effect.scoped(
|
|
617
|
+
Effect.gen(function* () {
|
|
618
|
+
const { child, startedAt } = yield* openProcess(request)
|
|
619
|
+
const output = yield* Effect.tryPromise({
|
|
620
|
+
try: () => child.output(),
|
|
621
|
+
catch: mapExecError,
|
|
622
|
+
})
|
|
623
|
+
const completedAt = yield* Clock.currentTimeMillis
|
|
624
|
+
return {
|
|
625
|
+
exitCode: output.status.code,
|
|
626
|
+
stdout: output.stdoutText ?? "",
|
|
627
|
+
stderr: output.stderrText ?? "",
|
|
628
|
+
durationMs: completedAt - startedAt,
|
|
629
|
+
} satisfies ExecResult
|
|
630
|
+
}),
|
|
631
|
+
),
|
|
632
|
+
),
|
|
633
|
+
|
|
634
|
+
execStream: (request) =>
|
|
635
|
+
Stream.unwrap(
|
|
636
|
+
Effect.map(openProcess(request), ({ child, startedAt }) => eventStream(child, startedAt)),
|
|
637
|
+
),
|
|
638
|
+
|
|
639
|
+
spawn: (request) =>
|
|
640
|
+
Effect.map(
|
|
641
|
+
openProcess(request),
|
|
642
|
+
({ child, startedAt }): ProcessHandle => ({
|
|
643
|
+
pid: child.pid,
|
|
644
|
+
events: eventStream(child, startedAt),
|
|
645
|
+
kill: Effect.tryPromise({
|
|
646
|
+
try: () => child.kill("SIGTERM"),
|
|
647
|
+
catch: mapExecError,
|
|
648
|
+
}),
|
|
649
|
+
exit: Effect.tryPromise({
|
|
650
|
+
try: () => child.status,
|
|
651
|
+
catch: mapExecError,
|
|
652
|
+
}).pipe(Effect.map((status) => ({ exitCode: status.code }))),
|
|
653
|
+
}),
|
|
654
|
+
),
|
|
655
|
+
|
|
656
|
+
files: {
|
|
657
|
+
read: (path) => Effect.tryPromise({ try: () => sdk.fs.readFile(path), catch: mapFsError }),
|
|
658
|
+
write: (path, contents) =>
|
|
659
|
+
Effect.tryPromise({
|
|
660
|
+
try: () => sdk.fs.writeFile(path, toBytes(contents)),
|
|
661
|
+
catch: mapFsError,
|
|
662
|
+
}),
|
|
663
|
+
remove: (path) => Effect.tryPromise({ try: () => sdk.fs.remove(path), catch: mapFsError }),
|
|
664
|
+
mkdir: (path) =>
|
|
665
|
+
Effect.tryPromise({
|
|
666
|
+
try: () => sdk.fs.mkdir(path, { recursive: true }),
|
|
667
|
+
catch: mapFsError,
|
|
668
|
+
}),
|
|
669
|
+
list: (path) =>
|
|
670
|
+
Stream.fromAsyncIterable(sdk.fs.readDir(path), mapFsError).pipe(
|
|
671
|
+
Stream.map((e) => ({
|
|
672
|
+
path: e.name,
|
|
673
|
+
kind: e.isFile
|
|
674
|
+
? ("file" as const)
|
|
675
|
+
: e.isDirectory
|
|
676
|
+
? ("directory" as const)
|
|
677
|
+
: e.isSymlink
|
|
678
|
+
? ("symlink" as const)
|
|
679
|
+
: ("other" as const),
|
|
680
|
+
})),
|
|
681
|
+
Stream.runCollect,
|
|
682
|
+
),
|
|
683
|
+
exists: (path) =>
|
|
684
|
+
Effect.tryPromise({
|
|
685
|
+
try: () => sdk.fs.stat(path),
|
|
686
|
+
catch: mapFsError,
|
|
687
|
+
}).pipe(
|
|
688
|
+
Effect.as(true),
|
|
689
|
+
Effect.catchCause(() => Effect.succeed(false)),
|
|
690
|
+
),
|
|
691
|
+
},
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
portRegistry.set(instance, sdk)
|
|
695
|
+
return instance
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// Service factory
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
const buildService = (config: DenoSandboxConfig): DenoSandboxService => {
|
|
703
|
+
const clientOpts = baseClientOptions(config)
|
|
704
|
+
const client = new Client(clientOpts)
|
|
705
|
+
const portRegistry = new WeakMap<SandboxInstance, DenoSdkSandbox>()
|
|
706
|
+
|
|
707
|
+
const buildOptions = (
|
|
708
|
+
request: DenoSandboxCreateRequest,
|
|
709
|
+
): Effect.Effect<SandboxOptions, SandboxError.SandboxError> =>
|
|
710
|
+
Effect.gen(function* () {
|
|
711
|
+
const img = yield* imageStep(request.image)
|
|
712
|
+
const net = yield* request.network === undefined
|
|
713
|
+
? Effect.succeed(noop)
|
|
714
|
+
: networkStep(request.network)
|
|
715
|
+
const vols = yield* volumesStep(request.volumes)
|
|
716
|
+
|
|
717
|
+
const steps: ReadonlyArray<Step> = [
|
|
718
|
+
img,
|
|
719
|
+
when(request.region ?? config.defaultRegion, (r) => set("region", r)),
|
|
720
|
+
when(request.env, (env) => set("env", { ...env })),
|
|
721
|
+
when(request.timeout, (t) => set("timeout", toDenoSeconds(t))),
|
|
722
|
+
when(request.memory, (m) => set("memory", Memory.toBytes(m))),
|
|
723
|
+
when(request.labels, (l) => set("labels", { ...l })),
|
|
724
|
+
net,
|
|
725
|
+
vols,
|
|
726
|
+
when(request.secrets, (s) => (s.length === 0 ? noop : secretsStep(s))),
|
|
727
|
+
when(request.port, (p) => set("port", p)),
|
|
728
|
+
]
|
|
729
|
+
const overlay = Arr.reduce(steps, {} as OptionsPatch, (acc, step) => step(acc))
|
|
730
|
+
return { ...clientOpts, ...overlay }
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
const acquireSdk = (request: DenoSandboxCreateRequest) =>
|
|
734
|
+
Effect.flatMap(buildOptions(request), (opts) =>
|
|
735
|
+
Effect.tryPromise({ try: () => DenoSdkSandbox.create(opts), catch: mapCreateError }),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
const releaseSdk = (sdk: DenoSdkSandbox) =>
|
|
739
|
+
Effect.promise(() => sdk.kill().catch(() => undefined)).pipe(Effect.ignore)
|
|
740
|
+
|
|
741
|
+
const releaseAttached = (sdk: DenoSdkSandbox) =>
|
|
742
|
+
Effect.promise(() => sdk.close().catch(() => undefined)).pipe(Effect.ignore)
|
|
743
|
+
|
|
744
|
+
const destroyById = (id: SandboxId) =>
|
|
745
|
+
Effect.tryPromise({
|
|
746
|
+
try: () => DenoSdkSandbox.connect(id, clientOpts),
|
|
747
|
+
catch: mapLookupError(id),
|
|
748
|
+
}).pipe(
|
|
749
|
+
Effect.flatMap((sdk) =>
|
|
750
|
+
Effect.tryPromise({ try: () => sdk.kill(), catch: mapLookupError(id) }),
|
|
751
|
+
),
|
|
752
|
+
Effect.catchTag("SandboxNotFound", () => Effect.void),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
create: (request) =>
|
|
757
|
+
Effect.acquireRelease(acquireSdk(request), releaseSdk).pipe(
|
|
758
|
+
Effect.map((sdk) => adaptInstance(sdk, portRegistry)),
|
|
759
|
+
),
|
|
760
|
+
|
|
761
|
+
attach: (id) =>
|
|
762
|
+
Effect.acquireRelease(
|
|
763
|
+
Effect.tryPromise({
|
|
764
|
+
try: () => DenoSdkSandbox.connect(id, clientOpts),
|
|
765
|
+
catch: mapLookupError(id),
|
|
766
|
+
}),
|
|
767
|
+
releaseAttached,
|
|
768
|
+
).pipe(Effect.map((sdk) => adaptInstance(sdk, portRegistry))),
|
|
769
|
+
|
|
770
|
+
list: Effect.tryPromise({
|
|
771
|
+
try: () => client.sandboxes.list(),
|
|
772
|
+
catch: mapCreateError,
|
|
773
|
+
}).pipe(Effect.map(Arr.map((m): SandboxRef => ({ id: SandboxId(m.id) })))),
|
|
774
|
+
|
|
775
|
+
destroy: destroyById,
|
|
776
|
+
|
|
777
|
+
snapshotVolume: (volumeId, slug) =>
|
|
778
|
+
Effect.gen(function* () {
|
|
779
|
+
const snapSlug = slug ?? `eff-uai-snap-${yield* randomToken}`
|
|
780
|
+
const snap = yield* Effect.tryPromise({
|
|
781
|
+
try: () => client.volumes.snapshot(volumeId, { slug: snapSlug }),
|
|
782
|
+
catch: mapCreateError,
|
|
783
|
+
})
|
|
784
|
+
return SnapshotId(snap.slug)
|
|
785
|
+
}),
|
|
786
|
+
|
|
787
|
+
snapshots: {
|
|
788
|
+
create: () =>
|
|
789
|
+
Effect.fail(
|
|
790
|
+
new SandboxError.SandboxUnsupported({
|
|
791
|
+
provider: PROVIDER,
|
|
792
|
+
capability: "snapshots.create",
|
|
793
|
+
reason:
|
|
794
|
+
"deno snapshots are derived from volumes, not from running sandboxes — use DenoSandbox.snapshotVolume(volumeId, slug?) instead",
|
|
795
|
+
}),
|
|
796
|
+
),
|
|
797
|
+
destroy: (sid) =>
|
|
798
|
+
Effect.tryPromise({
|
|
799
|
+
try: () => client.snapshots.delete(sid),
|
|
800
|
+
catch: mapLookupError(sid),
|
|
801
|
+
}),
|
|
802
|
+
list: Stream.unwrap(
|
|
803
|
+
Effect.tryPromise({
|
|
804
|
+
try: () => client.snapshots.list(),
|
|
805
|
+
catch: mapCreateError,
|
|
806
|
+
}).pipe(Effect.map((page) => Stream.fromAsyncIterable(page, mapCreateError))),
|
|
807
|
+
).pipe(
|
|
808
|
+
Stream.map((snap) => ({ id: SnapshotId(snap.id), name: snap.slug })),
|
|
809
|
+
Stream.runCollect,
|
|
810
|
+
),
|
|
811
|
+
},
|
|
812
|
+
|
|
813
|
+
volumes: {
|
|
814
|
+
create: (name, options) =>
|
|
815
|
+
Effect.tryPromise({
|
|
816
|
+
try: () =>
|
|
817
|
+
client.volumes.create({
|
|
818
|
+
slug: name,
|
|
819
|
+
// Volumes are only available in `ord` at the moment.
|
|
820
|
+
// Callers needing a different region can pre-create via
|
|
821
|
+
// the SDK directly.
|
|
822
|
+
region: config.defaultRegion ?? "ord",
|
|
823
|
+
capacity: options?.quotaBytes ?? "1GB",
|
|
824
|
+
}),
|
|
825
|
+
catch: mapCreateError,
|
|
826
|
+
}).pipe(Effect.map((vol) => VolumeId(vol.slug))),
|
|
827
|
+
destroy: (vid) =>
|
|
828
|
+
Effect.tryPromise({
|
|
829
|
+
try: () => client.volumes.delete(vid),
|
|
830
|
+
catch: mapLookupError(vid),
|
|
831
|
+
}),
|
|
832
|
+
list: Stream.unwrap(
|
|
833
|
+
Effect.tryPromise({
|
|
834
|
+
try: () => client.volumes.list(),
|
|
835
|
+
catch: mapCreateError,
|
|
836
|
+
}).pipe(Effect.map((page) => Stream.fromAsyncIterable(page, mapCreateError))),
|
|
837
|
+
).pipe(
|
|
838
|
+
Stream.map((vol) => ({ id: VolumeId(vol.slug), name: vol.slug })),
|
|
839
|
+
Stream.runCollect,
|
|
840
|
+
),
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
ports: {
|
|
844
|
+
expose: (instance, port) =>
|
|
845
|
+
Effect.gen(function* () {
|
|
846
|
+
const sdk = portRegistry.get(instance)
|
|
847
|
+
if (sdk === undefined) {
|
|
848
|
+
return yield* Effect.fail(
|
|
849
|
+
new SandboxError.SandboxNotFound({ provider: PROVIDER, id: instance.id }),
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
const url = yield* Effect.tryPromise({
|
|
853
|
+
try: () => sdk.exposeHttp({ port }),
|
|
854
|
+
catch: mapExecError,
|
|
855
|
+
})
|
|
856
|
+
return { url }
|
|
857
|
+
}),
|
|
858
|
+
},
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
// Cross-provider upcast — reject custom header on BoundSecret since
|
|
864
|
+
// Deno doesn't support per-secret headers.
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
const rejectCustomHeader = (
|
|
868
|
+
secrets: CommonCreateRequest["secrets"],
|
|
869
|
+
): Effect.Effect<void, SandboxError.SandboxError> =>
|
|
870
|
+
secrets === undefined || !secrets.some((s) => s.header !== undefined)
|
|
871
|
+
? Effect.void
|
|
872
|
+
: Effect.fail(
|
|
873
|
+
new SandboxError.SandboxUnsupported({
|
|
874
|
+
provider: PROVIDER,
|
|
875
|
+
capability: "BoundSecret.header",
|
|
876
|
+
reason:
|
|
877
|
+
"deno always injects secrets as `Authorization: Bearer <value>` — custom headers aren't supported. Use the narrowed `DenoSandbox.create` surface, which omits this field.",
|
|
878
|
+
}),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
const upcastService = (s: DenoSandboxService): SandboxService => ({
|
|
882
|
+
...s,
|
|
883
|
+
create: (request) =>
|
|
884
|
+
rejectCustomHeader(request.secrets).pipe(
|
|
885
|
+
Effect.andThen(s.create(request as DenoSandboxCreateRequest)),
|
|
886
|
+
),
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// Layer
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Layer registering the Deno Sandbox adapter against `Sandbox`, with
|
|
895
|
+
* the capability markers Deno actually supports.
|
|
896
|
+
*
|
|
897
|
+
* Shipped:
|
|
898
|
+
* - {@link SandboxHostnameAllowlist} — `allowNet` accepts hostnames + wildcards.
|
|
899
|
+
* - {@link SandboxSecretInjection} — `secrets.<name>.value` is injected
|
|
900
|
+
* only on outbound HTTPS to bound hosts; in-VM env shows a placeholder.
|
|
901
|
+
* - {@link SandboxSnapshots} — read-side helpers (`list`, `destroy`)
|
|
902
|
+
* work. `snapshots.create(fromInstance)` fails with
|
|
903
|
+
* `SandboxUnsupported`; use the per-provider
|
|
904
|
+
* `DenoSandbox.snapshotVolume` escape hatch for the volume-derived
|
|
905
|
+
* snapshot workflow.
|
|
906
|
+
* - {@link SandboxVolumes} — `client.volumes.*` directly.
|
|
907
|
+
* - {@link SandboxPortExposure} — `sandbox.exposeHttp({ port })`.
|
|
908
|
+
*
|
|
909
|
+
* NOT shipped (calling those helpers fails at compile time):
|
|
910
|
+
* - `SandboxCustomImage` — no Dockerfile / no OCI registry refs.
|
|
911
|
+
* - `SandboxPauseResume` — Deno doesn't offer in-place memory-preserving pause.
|
|
912
|
+
* - `SandboxKernelSession`, `SandboxPty` — no Jupyter / no PTY.
|
|
913
|
+
*/
|
|
914
|
+
export const layer = (config: DenoSandboxConfig = {}) => {
|
|
915
|
+
const service = buildService(config)
|
|
916
|
+
return Layer.mergeAll(
|
|
917
|
+
Layer.succeed(DenoSandbox, service),
|
|
918
|
+
Layer.succeed(CoreSandbox, upcastService(service)),
|
|
919
|
+
Layer.succeed(SandboxHostnameAllowlist, undefined),
|
|
920
|
+
Layer.succeed(SandboxSecretInjection, undefined),
|
|
921
|
+
Layer.succeed(SandboxSnapshots, undefined),
|
|
922
|
+
Layer.succeed(SandboxVolumes, undefined),
|
|
923
|
+
Layer.succeed(SandboxPortExposure, undefined),
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Re-export SDK error classes so consumers don't need a direct
|
|
928
|
+
// dependency on `@deno/sandbox` to do error-shape-narrowed handling.
|
|
929
|
+
export {
|
|
930
|
+
ApiError,
|
|
931
|
+
ConnectionClosedError,
|
|
932
|
+
ConnectionEstablishmentError,
|
|
933
|
+
InvalidMemoryError,
|
|
934
|
+
InvalidTimeoutError,
|
|
935
|
+
InvalidTokenError,
|
|
936
|
+
MissingTokenError,
|
|
937
|
+
SandboxCommandError,
|
|
938
|
+
SandboxKillError,
|
|
939
|
+
}
|