@yolk-sdk/sandbox 0.1.0-canary.18
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/README.md +60 -0
- package/dist/agent.d.mts +48 -0
- package/dist/agent.d.mts.map +1 -0
- package/dist/agent.mjs +173 -0
- package/dist/agent.mjs.map +1 -0
- package/dist/errors.d.mts +44 -0
- package/dist/errors.d.mts.map +1 -0
- package/dist/errors.mjs +40 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +7 -0
- package/dist/lifecycle.d.mts +52 -0
- package/dist/lifecycle.d.mts.map +1 -0
- package/dist/lifecycle.mjs +118 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/model.d.mts +84 -0
- package/dist/model.d.mts.map +1 -0
- package/dist/model.mjs +72 -0
- package/dist/model.mjs.map +1 -0
- package/dist/name.d.mts +5 -0
- package/dist/name.d.mts.map +1 -0
- package/dist/name.mjs +19 -0
- package/dist/name.mjs.map +1 -0
- package/dist/service.d.mts +15 -0
- package/dist/service.d.mts.map +1 -0
- package/dist/service.mjs +7 -0
- package/dist/service.mjs.map +1 -0
- package/dist/state-store.d.mts +18 -0
- package/dist/state-store.d.mts.map +1 -0
- package/dist/state-store.mjs +7 -0
- package/dist/state-store.mjs.map +1 -0
- package/dist/testing/index.d.mts +21 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +25 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/vercel/client.d.mts +70 -0
- package/dist/vercel/client.d.mts.map +1 -0
- package/dist/vercel/client.mjs +113 -0
- package/dist/vercel/client.mjs.map +1 -0
- package/dist/vercel/index.d.mts +3 -0
- package/dist/vercel/index.mjs +3 -0
- package/dist/vercel/layer.d.mts +23 -0
- package/dist/vercel/layer.d.mts.map +1 -0
- package/dist/vercel/layer.mjs +250 -0
- package/dist/vercel/layer.mjs.map +1 -0
- package/package.json +72 -0
- package/src/agent.ts +304 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +52 -0
- package/src/lifecycle.ts +186 -0
- package/src/model.ts +122 -0
- package/src/name.ts +23 -0
- package/src/service.ts +19 -0
- package/src/state-store.ts +17 -0
- package/src/testing/index.ts +63 -0
- package/src/vercel/client.ts +223 -0
- package/src/vercel/index.ts +24 -0
- package/src/vercel/layer.ts +396 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { Context, Layer } from 'effect'
|
|
2
|
+
import { APIError, Sandbox as VercelSdkSandbox, type Command, type CommandFinished, type NetworkPolicy } from '@vercel/sandbox'
|
|
3
|
+
import type { SandboxInitialSource, SandboxResources, SandboxSnapshotRetention } from '../model.ts'
|
|
4
|
+
|
|
5
|
+
export type VercelSandboxRuntime = 'node22' | 'node24' | 'node26' | 'python3.13'
|
|
6
|
+
export type VercelNetworkPolicy = NetworkPolicy
|
|
7
|
+
|
|
8
|
+
export type VercelCommandOutput = {
|
|
9
|
+
readonly stdout: string
|
|
10
|
+
readonly stderr: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type VercelCommandSignal = 'SIGTERM' | 'SIGKILL'
|
|
14
|
+
|
|
15
|
+
export type VercelFinishedCommand = {
|
|
16
|
+
readonly id: string
|
|
17
|
+
readonly exitCode: number
|
|
18
|
+
readonly output: () => Promise<VercelCommandOutput>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type VercelDetachedCommand = {
|
|
22
|
+
readonly id: string
|
|
23
|
+
readonly exitCode: number | null
|
|
24
|
+
readonly wait: () => Promise<VercelFinishedCommand>
|
|
25
|
+
readonly kill: (signal: VercelCommandSignal) => Promise<void>
|
|
26
|
+
readonly output: () => Promise<VercelCommandOutput>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type VercelSandboxFile = {
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly content: string
|
|
32
|
+
readonly mode?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type VercelRunCommandInput = {
|
|
36
|
+
readonly cmd: string
|
|
37
|
+
readonly args?: ReadonlyArray<string>
|
|
38
|
+
readonly cwd?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type VercelSandboxCreateInput = {
|
|
42
|
+
readonly name: string
|
|
43
|
+
readonly source: SandboxInitialSource
|
|
44
|
+
readonly ports: ReadonlyArray<number>
|
|
45
|
+
readonly timeoutMs: number
|
|
46
|
+
readonly resources: SandboxResources
|
|
47
|
+
readonly runtime: VercelSandboxRuntime
|
|
48
|
+
readonly env?: Readonly<Record<string, string>>
|
|
49
|
+
readonly persistent: boolean
|
|
50
|
+
readonly snapshotExpirationMs?: number
|
|
51
|
+
readonly keepLastSnapshots?: SandboxSnapshotRetention
|
|
52
|
+
readonly networkPolicy?: VercelNetworkPolicy
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type VercelSandboxGetInput = {
|
|
56
|
+
readonly name: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type VercelSandboxHandle = {
|
|
60
|
+
readonly name: string
|
|
61
|
+
readonly writeFiles: (files: ReadonlyArray<VercelSandboxFile>) => Promise<void>
|
|
62
|
+
readonly runDetachedCommand: (input: VercelRunCommandInput) => Promise<VercelDetachedCommand>
|
|
63
|
+
readonly getCommand: (id: string) => Promise<VercelDetachedCommand>
|
|
64
|
+
readonly extendTimeout: (durationMs: number) => Promise<void>
|
|
65
|
+
readonly delete: () => Promise<void>
|
|
66
|
+
readonly domain: (port: number) => string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type VercelSandboxClientApi = {
|
|
70
|
+
readonly get: (input: VercelSandboxGetInput) => Promise<VercelSandboxHandle | null>
|
|
71
|
+
readonly create: (input: VercelSandboxCreateInput) => Promise<VercelSandboxHandle>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class VercelSandboxClient extends Context.Service<VercelSandboxClient, VercelSandboxClientApi>()(
|
|
75
|
+
'@yolk-sdk/sandbox/vercel/VercelSandboxClient'
|
|
76
|
+
) {}
|
|
77
|
+
|
|
78
|
+
export const isVercelMissingSandboxError = (error: unknown) =>
|
|
79
|
+
error instanceof APIError && (error.response.status === 404 || error.response.status === 410)
|
|
80
|
+
|
|
81
|
+
const commandOutput = (command: Command | CommandFinished): Promise<VercelCommandOutput> =>
|
|
82
|
+
Promise.all([command.stdout(), command.stderr()]).then(([stdout, stderr]) => ({ stdout, stderr }))
|
|
83
|
+
|
|
84
|
+
const toFinishedCommand = (command: CommandFinished): VercelFinishedCommand => ({
|
|
85
|
+
id: command.cmdId,
|
|
86
|
+
exitCode: command.exitCode,
|
|
87
|
+
output: () => commandOutput(command)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const toDetachedCommand = (command: Command): VercelDetachedCommand => ({
|
|
91
|
+
id: command.cmdId,
|
|
92
|
+
exitCode: command.exitCode,
|
|
93
|
+
wait: () => command.wait().then(toFinishedCommand),
|
|
94
|
+
kill: signal => command.kill(signal),
|
|
95
|
+
output: () => commandOutput(command)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const toHandle = (sandbox: VercelSdkSandbox): VercelSandboxHandle => ({
|
|
99
|
+
name: sandbox.name,
|
|
100
|
+
writeFiles: files =>
|
|
101
|
+
sandbox.writeFiles(files.map(file => ({
|
|
102
|
+
path: file.path,
|
|
103
|
+
content: file.content,
|
|
104
|
+
...(file.mode === undefined ? {} : { mode: file.mode })
|
|
105
|
+
}))),
|
|
106
|
+
runDetachedCommand: input =>
|
|
107
|
+
sandbox.runCommand({
|
|
108
|
+
cmd: input.cmd,
|
|
109
|
+
args: input.args === undefined ? [] : [...input.args],
|
|
110
|
+
...(input.cwd === undefined ? {} : { cwd: input.cwd }),
|
|
111
|
+
detached: true
|
|
112
|
+
}).then(toDetachedCommand),
|
|
113
|
+
getCommand: id => sandbox.getCommand(id).then(toDetachedCommand),
|
|
114
|
+
extendTimeout: durationMs => sandbox.extendTimeout(durationMs).then(() => undefined),
|
|
115
|
+
delete: () => sandbox.delete().then(() => undefined),
|
|
116
|
+
domain: port => sandbox.domain(port)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const envObject = (env?: Readonly<Record<string, string>>) =>
|
|
120
|
+
env === undefined ? undefined : Object.fromEntries(Object.entries(env))
|
|
121
|
+
|
|
122
|
+
const retentionObject = (retention?: SandboxSnapshotRetention) =>
|
|
123
|
+
retention === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: {
|
|
126
|
+
count: retention.count,
|
|
127
|
+
...(retention.expirationMs === undefined ? {} : { expiration: retention.expirationMs }),
|
|
128
|
+
...(retention.deleteEvicted === undefined ? {} : { deleteEvicted: retention.deleteEvicted })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type AnonymousGitSource = {
|
|
132
|
+
readonly type: 'git'
|
|
133
|
+
readonly url: string
|
|
134
|
+
readonly depth?: number
|
|
135
|
+
readonly revision?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type BasicAuthGitSource = AnonymousGitSource & {
|
|
139
|
+
readonly username: string
|
|
140
|
+
readonly password: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const commonCreateOptions = (input: VercelSandboxCreateInput) => ({
|
|
144
|
+
name: input.name,
|
|
145
|
+
ports: [...input.ports],
|
|
146
|
+
timeout: input.timeoutMs,
|
|
147
|
+
resources: { vcpus: input.resources.vcpus },
|
|
148
|
+
env: envObject(input.env),
|
|
149
|
+
persistent: input.persistent,
|
|
150
|
+
networkPolicy: input.networkPolicy,
|
|
151
|
+
snapshotExpiration: input.snapshotExpirationMs,
|
|
152
|
+
keepLastSnapshots: retentionObject(input.keepLastSnapshots)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const gitSource = (
|
|
156
|
+
source: Extract<SandboxInitialSource, { readonly _tag: 'Git' }>
|
|
157
|
+
): AnonymousGitSource | BasicAuthGitSource => {
|
|
158
|
+
if ((source.username === undefined) !== (source.password === undefined)) {
|
|
159
|
+
throw new Error('Vercel git source requires both username and password when using basic auth')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (source.username !== undefined && source.password !== undefined) {
|
|
163
|
+
return {
|
|
164
|
+
type: 'git',
|
|
165
|
+
url: source.url,
|
|
166
|
+
username: source.username,
|
|
167
|
+
password: source.password,
|
|
168
|
+
...(source.depth === undefined ? {} : { depth: source.depth }),
|
|
169
|
+
...(source.revision === undefined ? {} : { revision: source.revision })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
type: 'git',
|
|
175
|
+
url: source.url,
|
|
176
|
+
...(source.depth === undefined ? {} : { depth: source.depth }),
|
|
177
|
+
...(source.revision === undefined ? {} : { revision: source.revision })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const createSandbox = (input: VercelSandboxCreateInput) => {
|
|
182
|
+
const common = commonCreateOptions(input)
|
|
183
|
+
|
|
184
|
+
switch (input.source._tag) {
|
|
185
|
+
case 'Empty':
|
|
186
|
+
return VercelSdkSandbox.create({
|
|
187
|
+
...common,
|
|
188
|
+
runtime: input.runtime
|
|
189
|
+
})
|
|
190
|
+
case 'Snapshot':
|
|
191
|
+
return VercelSdkSandbox.create({
|
|
192
|
+
...common,
|
|
193
|
+
source: { type: 'snapshot', snapshotId: input.source.snapshotId }
|
|
194
|
+
})
|
|
195
|
+
case 'Git':
|
|
196
|
+
return VercelSdkSandbox.create({
|
|
197
|
+
...common,
|
|
198
|
+
runtime: input.runtime,
|
|
199
|
+
source: gitSource(input.source)
|
|
200
|
+
})
|
|
201
|
+
case 'Tarball':
|
|
202
|
+
return VercelSdkSandbox.create({
|
|
203
|
+
...common,
|
|
204
|
+
runtime: input.runtime,
|
|
205
|
+
source: { type: 'tarball', url: input.source.url }
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const VercelSandboxClientLive = Layer.succeed(
|
|
211
|
+
VercelSandboxClient,
|
|
212
|
+
VercelSandboxClient.of({
|
|
213
|
+
get: input =>
|
|
214
|
+
VercelSdkSandbox.get({ name: input.name }).then(toHandle).catch(error => {
|
|
215
|
+
if (isVercelMissingSandboxError(error)) {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw error
|
|
220
|
+
}),
|
|
221
|
+
create: input => createSandbox(input).then(toHandle)
|
|
222
|
+
})
|
|
223
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export {
|
|
2
|
+
isVercelMissingSandboxError,
|
|
3
|
+
VercelSandboxClient,
|
|
4
|
+
VercelSandboxClientLive
|
|
5
|
+
} from './client.ts'
|
|
6
|
+
export {
|
|
7
|
+
makeVercelSandboxLayer,
|
|
8
|
+
makeVercelSandboxLayerWithClient
|
|
9
|
+
} from './layer.ts'
|
|
10
|
+
export type {
|
|
11
|
+
VercelCommandOutput,
|
|
12
|
+
VercelCommandSignal,
|
|
13
|
+
VercelDetachedCommand,
|
|
14
|
+
VercelFinishedCommand,
|
|
15
|
+
VercelNetworkPolicy,
|
|
16
|
+
VercelRunCommandInput,
|
|
17
|
+
VercelSandboxClientApi,
|
|
18
|
+
VercelSandboxCreateInput,
|
|
19
|
+
VercelSandboxFile,
|
|
20
|
+
VercelSandboxGetInput,
|
|
21
|
+
VercelSandboxHandle,
|
|
22
|
+
VercelSandboxRuntime
|
|
23
|
+
} from './client.ts'
|
|
24
|
+
export type { VercelSandboxLayerConfig } from './layer.ts'
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { Array as Arr, Clock, Duration, Effect, Layer, Option, Ref, Semaphore } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
absoluteSandboxCwd,
|
|
4
|
+
backgroundSandboxProbeMs,
|
|
5
|
+
defaultSandboxPorts,
|
|
6
|
+
defaultSandboxWorkspaceRoot,
|
|
7
|
+
initialSandboxState,
|
|
8
|
+
normalizeWorkspaceCwd,
|
|
9
|
+
sandboxCommandTimeoutMs,
|
|
10
|
+
sandboxLifecycleOrDefault,
|
|
11
|
+
sandboxSourceOrDefault,
|
|
12
|
+
sandboxStateDecision,
|
|
13
|
+
sandboxTimeoutExtendDeltaMs,
|
|
14
|
+
touchSandboxState,
|
|
15
|
+
validateSandboxCommand
|
|
16
|
+
} from '../lifecycle.ts'
|
|
17
|
+
import { makeVercelSandboxName } from '../name.ts'
|
|
18
|
+
import {
|
|
19
|
+
SandboxCommandResult,
|
|
20
|
+
type SandboxCommandInput,
|
|
21
|
+
SandboxPreviewUrl,
|
|
22
|
+
SandboxResources,
|
|
23
|
+
type SandboxInitialSource,
|
|
24
|
+
type SandboxLifecycle,
|
|
25
|
+
type SandboxState
|
|
26
|
+
} from '../model.ts'
|
|
27
|
+
import { Sandbox } from '../service.ts'
|
|
28
|
+
import { SandboxStateStore } from '../state-store.ts'
|
|
29
|
+
import { SandboxProviderError, unknownToMessage } from '../errors.ts'
|
|
30
|
+
import type { SandboxError } from '../errors.ts'
|
|
31
|
+
import {
|
|
32
|
+
VercelSandboxClient,
|
|
33
|
+
VercelSandboxClientLive,
|
|
34
|
+
type VercelDetachedCommand,
|
|
35
|
+
type VercelNetworkPolicy,
|
|
36
|
+
type VercelSandboxHandle,
|
|
37
|
+
type VercelSandboxRuntime
|
|
38
|
+
} from './client.ts'
|
|
39
|
+
|
|
40
|
+
export type VercelSandboxLayerConfig = {
|
|
41
|
+
readonly sandboxSessionId: string
|
|
42
|
+
readonly lifecycle?: SandboxLifecycle
|
|
43
|
+
readonly source?: SandboxInitialSource
|
|
44
|
+
readonly env?: Readonly<Record<string, string>>
|
|
45
|
+
readonly ports?: ReadonlyArray<number>
|
|
46
|
+
readonly resources?: SandboxResources
|
|
47
|
+
readonly runtime?: VercelSandboxRuntime
|
|
48
|
+
readonly networkPolicy?: VercelNetworkPolicy
|
|
49
|
+
readonly workspaceRoot?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ActiveSandbox = {
|
|
53
|
+
readonly handle: VercelSandboxHandle
|
|
54
|
+
readonly state: SandboxState
|
|
55
|
+
readonly workspaceReset: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type CommandRunOutput = {
|
|
59
|
+
readonly exitCode: number | null
|
|
60
|
+
readonly stdout: string
|
|
61
|
+
readonly stderr: string
|
|
62
|
+
readonly timedOut: boolean
|
|
63
|
+
readonly backgroundId?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const defaultVercelResources = SandboxResources.make({ vcpus: 2 })
|
|
67
|
+
const defaultVercelRuntime: VercelSandboxRuntime = 'node24'
|
|
68
|
+
|
|
69
|
+
const providerError = (operation: string, error: unknown) =>
|
|
70
|
+
new SandboxProviderError({
|
|
71
|
+
provider: 'vercel',
|
|
72
|
+
operation,
|
|
73
|
+
message: `Vercel Sandbox ${operation} failed: ${unknownToMessage(error)}`,
|
|
74
|
+
underlying: error
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const tryProvider = <A>(operation: string, body: () => Promise<A>) =>
|
|
78
|
+
Effect.tryPromise({
|
|
79
|
+
try: body,
|
|
80
|
+
catch: error => providerError(operation, error)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const lifecycleTimeoutMs = (lifecycle: SandboxLifecycle) => {
|
|
84
|
+
switch (lifecycle._tag) {
|
|
85
|
+
case 'Disposable':
|
|
86
|
+
return Math.min(lifecycle.idleTtlMs, lifecycle.maxLifetimeMs)
|
|
87
|
+
case 'Persistent':
|
|
88
|
+
return lifecycle.idleTtlMs
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const persistentFlag = (lifecycle: SandboxLifecycle) => lifecycle._tag === 'Persistent'
|
|
93
|
+
|
|
94
|
+
const snapshotExpirationMs = (lifecycle: SandboxLifecycle) =>
|
|
95
|
+
lifecycle._tag === 'Persistent' ? lifecycle.snapshotExpirationMs : undefined
|
|
96
|
+
|
|
97
|
+
const keepLastSnapshots = (lifecycle: SandboxLifecycle) =>
|
|
98
|
+
lifecycle._tag === 'Persistent' ? lifecycle.keepLastSnapshots : undefined
|
|
99
|
+
|
|
100
|
+
const previewUrls = (handle: VercelSandboxHandle, ports: ReadonlyArray<number>) =>
|
|
101
|
+
Effect.forEach(ports, port =>
|
|
102
|
+
Effect.try({
|
|
103
|
+
try: () => handle.domain(port),
|
|
104
|
+
catch: error => error
|
|
105
|
+
}).pipe(
|
|
106
|
+
Effect.match({
|
|
107
|
+
onFailure: () => Option.none<SandboxPreviewUrl>(),
|
|
108
|
+
onSuccess: url => Option.some(SandboxPreviewUrl.make({ port, url }))
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
).pipe(Effect.map(Arr.getSomes))
|
|
112
|
+
|
|
113
|
+
const commandOutputOrEmpty = (command: VercelDetachedCommand) =>
|
|
114
|
+
tryProvider('command.output', command.output).pipe(
|
|
115
|
+
Effect.catchTag('SandboxProviderError', error =>
|
|
116
|
+
Effect.succeed({
|
|
117
|
+
stdout: '',
|
|
118
|
+
stderr: error.message
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const finishedCommandOutputOrEmpty = (command: {
|
|
124
|
+
readonly output: () => Promise<{ readonly stdout: string; readonly stderr: string }>
|
|
125
|
+
}) =>
|
|
126
|
+
tryProvider('command.output', command.output).pipe(
|
|
127
|
+
Effect.catchTag('SandboxProviderError', error =>
|
|
128
|
+
Effect.succeed({
|
|
129
|
+
stdout: '',
|
|
130
|
+
stderr: error.message
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const waitForegroundCommand = (
|
|
136
|
+
command: VercelDetachedCommand,
|
|
137
|
+
timeoutMs: number
|
|
138
|
+
): Effect.Effect<CommandRunOutput, SandboxProviderError> =>
|
|
139
|
+
tryProvider('command.wait', command.wait).pipe(
|
|
140
|
+
Effect.flatMap(finished =>
|
|
141
|
+
finishedCommandOutputOrEmpty(finished).pipe(
|
|
142
|
+
Effect.map(output => ({
|
|
143
|
+
exitCode: finished.exitCode,
|
|
144
|
+
stdout: output.stdout,
|
|
145
|
+
stderr: output.stderr,
|
|
146
|
+
timedOut: false
|
|
147
|
+
}))
|
|
148
|
+
)
|
|
149
|
+
),
|
|
150
|
+
Effect.timeoutOrElse({
|
|
151
|
+
duration: Duration.millis(timeoutMs),
|
|
152
|
+
orElse: () =>
|
|
153
|
+
Effect.gen(function* () {
|
|
154
|
+
yield* tryProvider('command.kill', () => command.kill('SIGKILL')).pipe(
|
|
155
|
+
Effect.catchTag('SandboxProviderError', () => Effect.void)
|
|
156
|
+
)
|
|
157
|
+
const output = yield* commandOutputOrEmpty(command)
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
exitCode: null,
|
|
161
|
+
stdout: output.stdout,
|
|
162
|
+
stderr: output.stderr,
|
|
163
|
+
timedOut: true
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const probeBackgroundCommand = (
|
|
170
|
+
handle: VercelSandboxHandle,
|
|
171
|
+
command: VercelDetachedCommand
|
|
172
|
+
): Effect.Effect<CommandRunOutput, SandboxProviderError> =>
|
|
173
|
+
Effect.sleep(Duration.millis(backgroundSandboxProbeMs)).pipe(
|
|
174
|
+
Effect.flatMap(() => tryProvider('command.get', () => handle.getCommand(command.id))),
|
|
175
|
+
Effect.flatMap(current => {
|
|
176
|
+
if (current.exitCode === null) {
|
|
177
|
+
return Effect.succeed({
|
|
178
|
+
exitCode: null,
|
|
179
|
+
stdout: '',
|
|
180
|
+
stderr: '',
|
|
181
|
+
timedOut: false,
|
|
182
|
+
backgroundId: command.id
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return commandOutputOrEmpty(current).pipe(
|
|
187
|
+
Effect.map(output => ({
|
|
188
|
+
exitCode: current.exitCode,
|
|
189
|
+
stdout: output.stdout,
|
|
190
|
+
stderr: output.stderr,
|
|
191
|
+
timedOut: false
|
|
192
|
+
}))
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const createCommandFiles = (input: {
|
|
198
|
+
readonly commandId: string
|
|
199
|
+
readonly workspaceRoot: string
|
|
200
|
+
readonly command: string
|
|
201
|
+
readonly stdin?: string
|
|
202
|
+
}) => {
|
|
203
|
+
const commandDir = `${input.workspaceRoot}/.yolk/commands/${input.commandId}`
|
|
204
|
+
const commandPath = `${commandDir}/command.sh`
|
|
205
|
+
const stdinPath = `${commandDir}/stdin.txt`
|
|
206
|
+
const wrapperPath = `${commandDir}/wrapper.sh`
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
wrapperPath,
|
|
210
|
+
files: [
|
|
211
|
+
{
|
|
212
|
+
path: commandPath,
|
|
213
|
+
content: `#!/usr/bin/env bash\n${input.command}\n`,
|
|
214
|
+
mode: 0o755
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
path: stdinPath,
|
|
218
|
+
content: input.stdin ?? ''
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
path: wrapperPath,
|
|
222
|
+
content: `#!/usr/bin/env bash\nbash ${JSON.stringify(commandPath)} < ${JSON.stringify(stdinPath)}\n`,
|
|
223
|
+
mode: 0o755
|
|
224
|
+
}
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const makeVercelSandboxLayerWithClient = (config: VercelSandboxLayerConfig) =>
|
|
230
|
+
Layer.effect(
|
|
231
|
+
Sandbox,
|
|
232
|
+
Effect.gen(function* () {
|
|
233
|
+
const store = yield* SandboxStateStore
|
|
234
|
+
const client = yield* VercelSandboxClient
|
|
235
|
+
const semaphore = yield* Semaphore.make(1)
|
|
236
|
+
const commandCounter = yield* Ref.make(0)
|
|
237
|
+
const lifecycle = sandboxLifecycleOrDefault(config.lifecycle)
|
|
238
|
+
const source = sandboxSourceOrDefault(config.source)
|
|
239
|
+
const ports = config.ports ?? defaultSandboxPorts
|
|
240
|
+
const resources = config.resources ?? defaultVercelResources
|
|
241
|
+
const runtime = config.runtime ?? defaultVercelRuntime
|
|
242
|
+
const workspaceRoot = config.workspaceRoot ?? defaultSandboxWorkspaceRoot
|
|
243
|
+
const name = makeVercelSandboxName(config.sandboxSessionId)
|
|
244
|
+
|
|
245
|
+
const deleteNamedSandbox = (sandboxName: string) =>
|
|
246
|
+
tryProvider('get', () => client.get({ name: sandboxName })).pipe(
|
|
247
|
+
Effect.flatMap(handle =>
|
|
248
|
+
handle === null
|
|
249
|
+
? Effect.void
|
|
250
|
+
: tryProvider('delete', () => handle.delete())
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const createSandbox = (input: { readonly nowMs: number; readonly workspaceReset: boolean }) =>
|
|
255
|
+
Effect.gen(function* () {
|
|
256
|
+
const handle = yield* tryProvider('create', () =>
|
|
257
|
+
client.create({
|
|
258
|
+
name,
|
|
259
|
+
source,
|
|
260
|
+
ports,
|
|
261
|
+
timeoutMs: lifecycleTimeoutMs(lifecycle),
|
|
262
|
+
resources,
|
|
263
|
+
runtime,
|
|
264
|
+
env: config.env,
|
|
265
|
+
persistent: persistentFlag(lifecycle),
|
|
266
|
+
snapshotExpirationMs: snapshotExpirationMs(lifecycle),
|
|
267
|
+
keepLastSnapshots: keepLastSnapshots(lifecycle),
|
|
268
|
+
networkPolicy: config.networkPolicy
|
|
269
|
+
})
|
|
270
|
+
)
|
|
271
|
+
const state = initialSandboxState({ name, nowMs: input.nowMs, lifecycle })
|
|
272
|
+
yield* store.save({ sandboxSessionId: config.sandboxSessionId, state })
|
|
273
|
+
|
|
274
|
+
return { handle, state, workspaceReset: input.workspaceReset }
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
const touchExistingSandbox = (input: {
|
|
278
|
+
readonly handle: VercelSandboxHandle
|
|
279
|
+
readonly state: SandboxState
|
|
280
|
+
readonly nowMs: number
|
|
281
|
+
readonly workspaceReset: boolean
|
|
282
|
+
}) =>
|
|
283
|
+
Effect.gen(function* () {
|
|
284
|
+
const touched = touchSandboxState({
|
|
285
|
+
state: input.state,
|
|
286
|
+
nowMs: input.nowMs,
|
|
287
|
+
lifecycle
|
|
288
|
+
})
|
|
289
|
+
const deltaMs = sandboxTimeoutExtendDeltaMs({ before: input.state, after: touched })
|
|
290
|
+
|
|
291
|
+
if (deltaMs > 0) {
|
|
292
|
+
yield* tryProvider('extendTimeout', () => input.handle.extendTimeout(deltaMs))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
yield* store.save({ sandboxSessionId: config.sandboxSessionId, state: touched })
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
handle: input.handle,
|
|
299
|
+
state: touched,
|
|
300
|
+
workspaceReset: input.workspaceReset
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const ensureSandbox = (nowMs: number): Effect.Effect<ActiveSandbox, SandboxError> =>
|
|
305
|
+
Effect.gen(function* () {
|
|
306
|
+
const loaded = yield* store.load(config.sandboxSessionId)
|
|
307
|
+
const decision = sandboxStateDecision({ state: loaded, name, nowMs })
|
|
308
|
+
|
|
309
|
+
if (decision._tag === 'Create') {
|
|
310
|
+
if (Option.isSome(loaded) && decision.workspaceReset) {
|
|
311
|
+
yield* deleteNamedSandbox(loaded.value.name)
|
|
312
|
+
yield* store.clear(config.sandboxSessionId)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return yield* createSandbox({ nowMs, workspaceReset: decision.workspaceReset })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const handle = yield* tryProvider('get', () => client.get({ name }))
|
|
319
|
+
|
|
320
|
+
if (handle === null) {
|
|
321
|
+
yield* store.clear(config.sandboxSessionId)
|
|
322
|
+
return yield* createSandbox({ nowMs, workspaceReset: true })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return yield* touchExistingSandbox({
|
|
326
|
+
handle,
|
|
327
|
+
state: decision.state,
|
|
328
|
+
nowMs,
|
|
329
|
+
workspaceReset: false
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
const nextCommandId = Ref.updateAndGet(commandCounter, value => value + 1).pipe(
|
|
334
|
+
Effect.map(value => `command-${value}`)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const runUnlocked = (input: SandboxCommandInput) =>
|
|
338
|
+
Effect.gen(function* () {
|
|
339
|
+
yield* validateSandboxCommand(input.command)
|
|
340
|
+
const normalizedCwd = yield* normalizeWorkspaceCwd(input.cwd)
|
|
341
|
+
const timeoutMs = sandboxCommandTimeoutMs(input.timeoutMs)
|
|
342
|
+
const nowMs = yield* Clock.currentTimeMillis
|
|
343
|
+
const active = yield* ensureSandbox(nowMs)
|
|
344
|
+
const id = yield* nextCommandId
|
|
345
|
+
const commandFiles = createCommandFiles({
|
|
346
|
+
commandId: id,
|
|
347
|
+
workspaceRoot,
|
|
348
|
+
command: input.command,
|
|
349
|
+
stdin: input.stdin
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
yield* tryProvider('writeFiles', () => active.handle.writeFiles(commandFiles.files))
|
|
353
|
+
|
|
354
|
+
const startedAtMs = yield* Clock.currentTimeMillis
|
|
355
|
+
const command = yield* tryProvider('runCommand', () =>
|
|
356
|
+
active.handle.runDetachedCommand({
|
|
357
|
+
cmd: 'bash',
|
|
358
|
+
args: [commandFiles.wrapperPath],
|
|
359
|
+
cwd: absoluteSandboxCwd(workspaceRoot, normalizedCwd)
|
|
360
|
+
})
|
|
361
|
+
)
|
|
362
|
+
const output = yield* (input.background === true
|
|
363
|
+
? probeBackgroundCommand(active.handle, command)
|
|
364
|
+
: waitForegroundCommand(command, timeoutMs))
|
|
365
|
+
const endedAtMs = yield* Clock.currentTimeMillis
|
|
366
|
+
const urls = yield* previewUrls(active.handle, ports)
|
|
367
|
+
|
|
368
|
+
return SandboxCommandResult.make({
|
|
369
|
+
exitCode: output.exitCode,
|
|
370
|
+
stdout: output.stdout,
|
|
371
|
+
stderr: output.stderr,
|
|
372
|
+
durationMs: Math.max(0, endedAtMs - startedAtMs),
|
|
373
|
+
timedOut: output.timedOut,
|
|
374
|
+
workspaceReset: active.workspaceReset,
|
|
375
|
+
...(output.backgroundId === undefined ? {} : { backgroundId: output.backgroundId }),
|
|
376
|
+
previewUrls: urls,
|
|
377
|
+
state: active.state
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
return Sandbox.of({
|
|
382
|
+
run: input => semaphore.withPermits(1)(runUnlocked(input)),
|
|
383
|
+
currentState: store.load(config.sandboxSessionId),
|
|
384
|
+
delete: Effect.gen(function* () {
|
|
385
|
+
const loaded = yield* store.load(config.sandboxSessionId)
|
|
386
|
+
if (Option.isSome(loaded)) {
|
|
387
|
+
yield* deleteNamedSandbox(loaded.value.name)
|
|
388
|
+
}
|
|
389
|
+
yield* store.clear(config.sandboxSessionId)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
export const makeVercelSandboxLayer = (config: VercelSandboxLayerConfig) =>
|
|
396
|
+
makeVercelSandboxLayerWithClient(config).pipe(Layer.provide(VercelSandboxClientLive))
|