@ai-sdk/sandbox-vercel 0.0.0 → 1.0.0-canary.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/CHANGELOG.md +20 -0
- package/LICENSE +13 -0
- package/README.md +76 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +474 -0
- package/dist/index.js.map +1 -0
- package/package.json +72 -1
- package/src/index.ts +5 -0
- package/src/vercel-network-sandbox-session.ts +132 -0
- package/src/vercel-sandbox-session.ts +299 -0
- package/src/vercel-sandbox.ts +288 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HarnessV1NetworkSandboxSession,
|
|
3
|
+
HarnessV1SandboxProvider,
|
|
4
|
+
} from '@ai-sdk/harness';
|
|
5
|
+
import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
6
|
+
import { Sandbox } from '@vercel/sandbox';
|
|
7
|
+
import { VercelNetworkSandboxSession } from './vercel-network-sandbox-session';
|
|
8
|
+
import { VercelSandboxSession } from './vercel-sandbox-session';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Flattens an intersection of object types into a single object type so the
|
|
12
|
+
* resolved shape displays as its named properties rather than a chain of
|
|
13
|
+
* `A & B & C`.
|
|
14
|
+
*/
|
|
15
|
+
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Distributes `Omit` across each member of a union instead of collapsing the
|
|
19
|
+
* union to its common keys. `Sandbox.create`'s parameter is a union (a
|
|
20
|
+
* git/tarball/no-source create variant and a snapshot-source create variant),
|
|
21
|
+
* so a plain `Omit` would discard keys absent from any one member (e.g.
|
|
22
|
+
* `runtime`, which the snapshot variant lacks) and merge the `source` shapes.
|
|
23
|
+
* Applying `Omit` per-member preserves every variant intact; the `Prettify`
|
|
24
|
+
* wrapper collapses each member's intersections into a readable object shape.
|
|
25
|
+
*/
|
|
26
|
+
type DistributiveOmit<T, K extends keyof any> = T extends unknown
|
|
27
|
+
? Prettify<Omit<T, K>>
|
|
28
|
+
: never;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parameters forwarded to `@vercel/sandbox`'s `Sandbox.create` when creating
|
|
32
|
+
* a sandbox from scratch. Aliased directly from the underlying SDK so the
|
|
33
|
+
* full surface — every option Vercel supports, including its native
|
|
34
|
+
* `NetworkPolicy` — is available without us re-declaring it.
|
|
35
|
+
*/
|
|
36
|
+
type VercelSandboxCreateParams = DistributiveOmit<
|
|
37
|
+
NonNullable<Parameters<typeof Sandbox.create>[0]>,
|
|
38
|
+
'onResume'
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Settings for {@link createVercelSandbox}. Two mutually-exclusive shapes:
|
|
43
|
+
*
|
|
44
|
+
* - `{ sandbox }` — wrap an already-created `@vercel/sandbox` `Sandbox`. The
|
|
45
|
+
* caller owns its lifecycle; the provider's `stop()` and `destroy()` are
|
|
46
|
+
* no-ops. Optionally declare `bridgePorts` to give the harness a port pool to
|
|
47
|
+
* lease from for concurrent sessions on the same provided sandbox.
|
|
48
|
+
* - {@link VercelSandboxCreateParams} fields — provider creates the underlying
|
|
49
|
+
* sandbox. When the adapter declares a bootstrap recipe the provider uses
|
|
50
|
+
* `Sandbox.getOrCreate` to maintain a persistent named template snapshot
|
|
51
|
+
* keyed by the recipe identity, and forks an ephemeral sandbox per session
|
|
52
|
+
* from the snapshot. Use `name` to override the auto-derived template name.
|
|
53
|
+
*/
|
|
54
|
+
export type VercelSandboxSettings =
|
|
55
|
+
| {
|
|
56
|
+
sandbox: Sandbox;
|
|
57
|
+
bridgePorts?: ReadonlyArray<number>;
|
|
58
|
+
}
|
|
59
|
+
| (VercelSandboxCreateParams & {
|
|
60
|
+
sandbox?: never;
|
|
61
|
+
name?: string;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 30 minutes. The `@vercel/sandbox` SDK defaults to 5 minutes which is
|
|
66
|
+
* too short for multi-step workflows — the VM expires between steps.
|
|
67
|
+
*/
|
|
68
|
+
const DEFAULT_SANDBOX_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
69
|
+
|
|
70
|
+
const VERCEL_PROVIDER_ID = 'vercel-sandbox';
|
|
71
|
+
const TEMPLATE_NAME_PREFIX = 'ai-sdk-harness';
|
|
72
|
+
const SESSION_NAME_PREFIX = 'ai-sdk-harness-session';
|
|
73
|
+
const SNAPSHOT_POLL_INTERVAL_MS = 500;
|
|
74
|
+
const SNAPSHOT_POLL_TIMEOUT_MS = 30_000;
|
|
75
|
+
|
|
76
|
+
function sessionSandboxName(sessionId: string): string {
|
|
77
|
+
return `${SESSION_NAME_PREFIX}-${sessionId}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createVercelSandbox(
|
|
81
|
+
settings: VercelSandboxSettings = {} as VercelSandboxSettings,
|
|
82
|
+
): HarnessV1SandboxProvider {
|
|
83
|
+
return new VercelSandboxProvider(settings);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `HarnessV1SandboxProvider` implementation backed by `@vercel/sandbox`.
|
|
88
|
+
* Construct one via {@link createVercelSandbox} at module scope and pass it
|
|
89
|
+
* to a `HarnessAgent` (or call `createSession()` directly if you want raw
|
|
90
|
+
* access to a network sandbox session).
|
|
91
|
+
*/
|
|
92
|
+
export class VercelSandboxProvider implements HarnessV1SandboxProvider {
|
|
93
|
+
readonly specificationVersion = 'harness-sandbox-v1' as const;
|
|
94
|
+
readonly providerId = VERCEL_PROVIDER_ID;
|
|
95
|
+
readonly bridgePorts?: ReadonlyArray<number>;
|
|
96
|
+
|
|
97
|
+
constructor(private readonly settings: VercelSandboxSettings) {
|
|
98
|
+
if (
|
|
99
|
+
'sandbox' in settings &&
|
|
100
|
+
settings.sandbox != null &&
|
|
101
|
+
settings.bridgePorts != null &&
|
|
102
|
+
settings.bridgePorts.length > 0
|
|
103
|
+
) {
|
|
104
|
+
this.bridgePorts = [...settings.bridgePorts];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
createSession = async (options?: {
|
|
109
|
+
sessionId?: string;
|
|
110
|
+
abortSignal?: AbortSignal;
|
|
111
|
+
identity?: string;
|
|
112
|
+
onFirstCreate?: (
|
|
113
|
+
session: SandboxSession,
|
|
114
|
+
opts: { abortSignal?: AbortSignal },
|
|
115
|
+
) => Promise<void>;
|
|
116
|
+
}): Promise<HarnessV1NetworkSandboxSession> => {
|
|
117
|
+
options?.abortSignal?.throwIfAborted();
|
|
118
|
+
|
|
119
|
+
if ('sandbox' in this.settings && this.settings.sandbox != null) {
|
|
120
|
+
return new VercelNetworkSandboxSession({
|
|
121
|
+
sandbox: this.settings.sandbox,
|
|
122
|
+
ownsLifecycle: false,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type CreateNewBranch = BaseCreateSandboxParams & {
|
|
127
|
+
sandbox?: never;
|
|
128
|
+
name?: string;
|
|
129
|
+
};
|
|
130
|
+
const settings = this.settings as CreateNewBranch;
|
|
131
|
+
const {
|
|
132
|
+
sandbox: _ignoredSandbox,
|
|
133
|
+
name: explicitName,
|
|
134
|
+
...createParams
|
|
135
|
+
} = settings;
|
|
136
|
+
const baseParams: BaseCreateSandboxParams = {
|
|
137
|
+
...createParams,
|
|
138
|
+
timeout: createParams.timeout ?? DEFAULT_SANDBOX_TIMEOUT_MS,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const identity = options?.identity;
|
|
142
|
+
const onFirstCreate = options?.onFirstCreate;
|
|
143
|
+
|
|
144
|
+
// When sessionId is supplied, name the per-session sandbox deterministically
|
|
145
|
+
// so a future `resumeSession({ sessionId })` can locate it via
|
|
146
|
+
// `Sandbox.get({ name })`. Absent sessionId (e.g. prewarm), fall back to
|
|
147
|
+
// Vercel's auto-naming.
|
|
148
|
+
const sessionNameOverride = options?.sessionId
|
|
149
|
+
? { name: sessionSandboxName(options.sessionId) }
|
|
150
|
+
: {};
|
|
151
|
+
|
|
152
|
+
if (identity == null || onFirstCreate == null) {
|
|
153
|
+
const sandbox = await Sandbox.create({
|
|
154
|
+
...baseParams,
|
|
155
|
+
...sessionNameOverride,
|
|
156
|
+
...(options?.abortSignal ? { signal: options.abortSignal } : {}),
|
|
157
|
+
});
|
|
158
|
+
return new VercelNetworkSandboxSession({ sandbox, ownsLifecycle: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const templateName = explicitName ?? `${TEMPLATE_NAME_PREFIX}-${identity}`;
|
|
162
|
+
const cache = getSnapshotCache();
|
|
163
|
+
let snapshotId = cache.get(templateName);
|
|
164
|
+
|
|
165
|
+
if (snapshotId == null) {
|
|
166
|
+
const template = await Sandbox.getOrCreate({
|
|
167
|
+
...baseParams,
|
|
168
|
+
name: templateName,
|
|
169
|
+
persistent: true,
|
|
170
|
+
snapshotExpiration: baseParams.snapshotExpiration ?? 0,
|
|
171
|
+
onCreate: async sbx => {
|
|
172
|
+
await onFirstCreate(new VercelSandboxSession(sbx), {
|
|
173
|
+
abortSignal: options?.abortSignal,
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
...(options?.abortSignal ? { signal: options.abortSignal } : {}),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let resolvedId: string | undefined = template.currentSnapshotId;
|
|
180
|
+
if (resolvedId == null) {
|
|
181
|
+
const stopResult = await template.stop(
|
|
182
|
+
options?.abortSignal ? { signal: options.abortSignal } : undefined,
|
|
183
|
+
);
|
|
184
|
+
resolvedId = stopResult.snapshot?.id;
|
|
185
|
+
if (resolvedId == null) {
|
|
186
|
+
resolvedId = await pollForTemplateSnapshot(
|
|
187
|
+
templateName,
|
|
188
|
+
options?.abortSignal,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
cache.set(templateName, resolvedId);
|
|
194
|
+
snapshotId = resolvedId;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const {
|
|
198
|
+
runtime: _ignoredRuntime,
|
|
199
|
+
source: _ignoredSource,
|
|
200
|
+
persistent: _ignoredPersistent,
|
|
201
|
+
...forkParams
|
|
202
|
+
} = baseParams;
|
|
203
|
+
|
|
204
|
+
const fork = await Sandbox.create({
|
|
205
|
+
...forkParams,
|
|
206
|
+
source: { type: 'snapshot', snapshotId },
|
|
207
|
+
...sessionNameOverride,
|
|
208
|
+
...(options?.abortSignal ? { signal: options.abortSignal } : {}),
|
|
209
|
+
});
|
|
210
|
+
return new VercelNetworkSandboxSession({
|
|
211
|
+
sandbox: fork,
|
|
212
|
+
ownsLifecycle: true,
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
resumeSession = async (options: {
|
|
217
|
+
sessionId: string;
|
|
218
|
+
abortSignal?: AbortSignal;
|
|
219
|
+
}): Promise<HarnessV1NetworkSandboxSession> => {
|
|
220
|
+
options.abortSignal?.throwIfAborted();
|
|
221
|
+
|
|
222
|
+
// Wrap-existing case: caller owns the sandbox. Same session as createSession.
|
|
223
|
+
if ('sandbox' in this.settings && this.settings.sandbox != null) {
|
|
224
|
+
return new VercelNetworkSandboxSession({
|
|
225
|
+
sandbox: this.settings.sandbox,
|
|
226
|
+
ownsLifecycle: false,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sandbox = await Sandbox.get({
|
|
231
|
+
name: sessionSandboxName(options.sessionId),
|
|
232
|
+
...(options.abortSignal ? { signal: options.abortSignal } : {}),
|
|
233
|
+
});
|
|
234
|
+
return new VercelNetworkSandboxSession({ sandbox, ownsLifecycle: true });
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Base shape of `Sandbox.create` params extracted from the union (excludes
|
|
240
|
+
* the `source: { type: 'snapshot' }` variant) so all create-time fields
|
|
241
|
+
* are typed as present.
|
|
242
|
+
*/
|
|
243
|
+
type BaseCreateSandboxParams = Exclude<
|
|
244
|
+
VercelSandboxCreateParams,
|
|
245
|
+
{ source: { type: 'snapshot'; snapshotId: string } }
|
|
246
|
+
>;
|
|
247
|
+
|
|
248
|
+
const SNAPSHOT_CACHE_KEY = Symbol.for(
|
|
249
|
+
'ai-sdk.harness.vercel-template-snapshots',
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
type SnapshotCache = Map<string, string>;
|
|
253
|
+
|
|
254
|
+
function getSnapshotCache(): SnapshotCache {
|
|
255
|
+
const globals = globalThis as {
|
|
256
|
+
[SNAPSHOT_CACHE_KEY]?: SnapshotCache;
|
|
257
|
+
};
|
|
258
|
+
let cache = globals[SNAPSHOT_CACHE_KEY];
|
|
259
|
+
if (cache == null) {
|
|
260
|
+
cache = new Map();
|
|
261
|
+
globals[SNAPSHOT_CACHE_KEY] = cache;
|
|
262
|
+
}
|
|
263
|
+
return cache;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function pollForTemplateSnapshot(
|
|
267
|
+
name: string,
|
|
268
|
+
abortSignal: AbortSignal | undefined,
|
|
269
|
+
): Promise<string> {
|
|
270
|
+
const deadline = Date.now() + SNAPSHOT_POLL_TIMEOUT_MS;
|
|
271
|
+
while (Date.now() < deadline) {
|
|
272
|
+
abortSignal?.throwIfAborted();
|
|
273
|
+
const refreshed = await Sandbox.get({
|
|
274
|
+
name,
|
|
275
|
+
resume: false,
|
|
276
|
+
...(abortSignal ? { signal: abortSignal } : {}),
|
|
277
|
+
});
|
|
278
|
+
if (refreshed.currentSnapshotId) {
|
|
279
|
+
return refreshed.currentSnapshotId;
|
|
280
|
+
}
|
|
281
|
+
await new Promise<void>(resolve =>
|
|
282
|
+
setTimeout(resolve, SNAPSHOT_POLL_INTERVAL_MS),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Timed out waiting for snapshot of template "${name}" to publish.`,
|
|
287
|
+
);
|
|
288
|
+
}
|