@blaxel/core 0.2.86-preview.165 → 0.2.87-dev.173

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 (108) hide show
  1. package/README.md +37 -2
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/authentication/credentials.js +11 -1
  4. package/dist/cjs/authentication/index.js +3 -1
  5. package/dist/cjs/client/sdk.gen.js +63 -12
  6. package/dist/cjs/common/credentials.test.js +71 -0
  7. package/dist/cjs/common/errors.js +15 -0
  8. package/dist/cjs/common/h2fetch.js +101 -50
  9. package/dist/cjs/common/h2pool.js +9 -0
  10. package/dist/cjs/common/pagination.js +87 -0
  11. package/dist/cjs/common/pagination.test.js +62 -0
  12. package/dist/cjs/common/settings.js +41 -3
  13. package/dist/cjs/common/settings.test.js +12 -3
  14. package/dist/cjs/drive/index.js +39 -3
  15. package/dist/cjs/index.js +1 -0
  16. package/dist/cjs/jobs/jobs.js +42 -9
  17. package/dist/cjs/sandbox/client/sdk.gen.js +1 -1
  18. package/dist/cjs/sandbox/preview.js +8 -3
  19. package/dist/cjs/sandbox/sandbox.js +41 -4
  20. package/dist/cjs/types/authentication/credentials.d.ts +9 -0
  21. package/dist/cjs/types/client/sdk.gen.d.ts +34 -19
  22. package/dist/cjs/types/client/types.gen.d.ts +704 -22
  23. package/dist/cjs/types/common/credentials.test.d.ts +1 -0
  24. package/dist/cjs/types/common/errors.d.ts +10 -0
  25. package/dist/cjs/types/common/h2fetch.d.ts +9 -4
  26. package/dist/cjs/types/common/pagination.d.ts +35 -0
  27. package/dist/cjs/types/common/pagination.test.d.ts +1 -0
  28. package/dist/cjs/types/common/settings.d.ts +9 -0
  29. package/dist/cjs/types/drive/index.d.ts +35 -4
  30. package/dist/cjs/types/index.d.ts +1 -0
  31. package/dist/cjs/types/jobs/jobs.d.ts +33 -3
  32. package/dist/cjs/types/sandbox/client/sdk.gen.d.ts +1 -1
  33. package/dist/cjs/types/sandbox/client/types.gen.d.ts +24 -0
  34. package/dist/cjs/types/sandbox/preview.d.ts +2 -2
  35. package/dist/cjs/types/sandbox/sandbox.d.ts +36 -2
  36. package/dist/cjs/types/volume/index.d.ts +37 -4
  37. package/dist/cjs/volume/index.js +41 -3
  38. package/dist/cjs-browser/.tsbuildinfo +1 -1
  39. package/dist/cjs-browser/authentication/credentials.js +11 -1
  40. package/dist/cjs-browser/authentication/index.js +3 -1
  41. package/dist/cjs-browser/client/sdk.gen.js +63 -12
  42. package/dist/cjs-browser/common/credentials.test.js +71 -0
  43. package/dist/cjs-browser/common/errors.js +15 -0
  44. package/dist/cjs-browser/common/pagination.js +87 -0
  45. package/dist/cjs-browser/common/pagination.test.js +62 -0
  46. package/dist/cjs-browser/common/settings.js +41 -3
  47. package/dist/cjs-browser/common/settings.test.js +12 -3
  48. package/dist/cjs-browser/drive/index.js +39 -3
  49. package/dist/cjs-browser/index.js +1 -0
  50. package/dist/cjs-browser/jobs/jobs.js +42 -9
  51. package/dist/cjs-browser/sandbox/client/sdk.gen.js +1 -1
  52. package/dist/cjs-browser/sandbox/preview.js +8 -3
  53. package/dist/cjs-browser/sandbox/sandbox.js +41 -4
  54. package/dist/cjs-browser/types/authentication/credentials.d.ts +9 -0
  55. package/dist/cjs-browser/types/client/sdk.gen.d.ts +34 -19
  56. package/dist/cjs-browser/types/client/types.gen.d.ts +704 -22
  57. package/dist/cjs-browser/types/common/credentials.test.d.ts +1 -0
  58. package/dist/cjs-browser/types/common/errors.d.ts +10 -0
  59. package/dist/cjs-browser/types/common/h2fetch.d.ts +9 -4
  60. package/dist/cjs-browser/types/common/pagination.d.ts +35 -0
  61. package/dist/cjs-browser/types/common/pagination.test.d.ts +1 -0
  62. package/dist/cjs-browser/types/common/settings.d.ts +9 -0
  63. package/dist/cjs-browser/types/drive/index.d.ts +35 -4
  64. package/dist/cjs-browser/types/index.d.ts +1 -0
  65. package/dist/cjs-browser/types/jobs/jobs.d.ts +33 -3
  66. package/dist/cjs-browser/types/sandbox/client/sdk.gen.d.ts +1 -1
  67. package/dist/cjs-browser/types/sandbox/client/types.gen.d.ts +24 -0
  68. package/dist/cjs-browser/types/sandbox/preview.d.ts +2 -2
  69. package/dist/cjs-browser/types/sandbox/sandbox.d.ts +36 -2
  70. package/dist/cjs-browser/types/volume/index.d.ts +37 -4
  71. package/dist/cjs-browser/volume/index.js +41 -3
  72. package/dist/esm/.tsbuildinfo +1 -1
  73. package/dist/esm/authentication/credentials.js +9 -0
  74. package/dist/esm/authentication/index.js +4 -2
  75. package/dist/esm/client/sdk.gen.js +57 -9
  76. package/dist/esm/common/credentials.test.js +69 -0
  77. package/dist/esm/common/errors.js +13 -0
  78. package/dist/esm/common/h2fetch.js +101 -50
  79. package/dist/esm/common/h2pool.js +9 -0
  80. package/dist/esm/common/pagination.js +83 -0
  81. package/dist/esm/common/pagination.test.js +60 -0
  82. package/dist/esm/common/settings.js +41 -3
  83. package/dist/esm/common/settings.test.js +12 -3
  84. package/dist/esm/drive/index.js +39 -3
  85. package/dist/esm/index.js +1 -0
  86. package/dist/esm/jobs/jobs.js +42 -9
  87. package/dist/esm/sandbox/client/sdk.gen.js +1 -1
  88. package/dist/esm/sandbox/preview.js +8 -3
  89. package/dist/esm/sandbox/sandbox.js +41 -4
  90. package/dist/esm/volume/index.js +41 -3
  91. package/dist/esm-browser/.tsbuildinfo +1 -1
  92. package/dist/esm-browser/authentication/credentials.js +9 -0
  93. package/dist/esm-browser/authentication/index.js +4 -2
  94. package/dist/esm-browser/client/sdk.gen.js +57 -9
  95. package/dist/esm-browser/common/credentials.test.js +69 -0
  96. package/dist/esm-browser/common/errors.js +13 -0
  97. package/dist/esm-browser/common/pagination.js +83 -0
  98. package/dist/esm-browser/common/pagination.test.js +60 -0
  99. package/dist/esm-browser/common/settings.js +41 -3
  100. package/dist/esm-browser/common/settings.test.js +12 -3
  101. package/dist/esm-browser/drive/index.js +39 -3
  102. package/dist/esm-browser/index.js +1 -0
  103. package/dist/esm-browser/jobs/jobs.js +42 -9
  104. package/dist/esm-browser/sandbox/client/sdk.gen.js +1 -1
  105. package/dist/esm-browser/sandbox/preview.js +8 -3
  106. package/dist/esm-browser/sandbox/sandbox.js +41 -4
  107. package/dist/esm-browser/volume/index.js +41 -3
  108. package/package.json +1 -1
@@ -11,3 +11,12 @@ export class Credentials {
11
11
  return "";
12
12
  }
13
13
  }
14
+ /**
15
+ * Marker returned by `authentication()` when no usable Blaxel credentials were
16
+ * resolved (no env vars, no matching config workspace). Construction is
17
+ * side-effect free so importing `@blaxel/core` never fails; the actionable
18
+ * error is raised when an authenticated request is actually attempted
19
+ * (see `Settings.assertCredentials`).
20
+ */
21
+ export class MissingCredentials extends Credentials {
22
+ }
@@ -3,7 +3,7 @@ import { env } from "../common/env.js";
3
3
  import { fs, os, path } from "../common/node.js";
4
4
  import { ApiKey } from "./apikey.js";
5
5
  import { ClientCredentials } from "./clientcredentials.js";
6
- import { Credentials } from "./credentials.js";
6
+ import { Credentials, MissingCredentials } from "./credentials.js";
7
7
  import { DeviceMode } from "./deviceMode.js";
8
8
  function getCredentials() {
9
9
  if (env.BL_API_KEY) {
@@ -45,7 +45,9 @@ function getCredentials() {
45
45
  export function authentication() {
46
46
  const credentials = getCredentials();
47
47
  if (!credentials) {
48
- return new Credentials();
48
+ // Never silently fall back to empty headers: a marker that triggers a
49
+ // clear CredentialsError when an authenticated request is attempted.
50
+ return new MissingCredentials();
49
51
  }
50
52
  if (credentials.apiKey) {
51
53
  return new ApiKey(credentials);
@@ -2,7 +2,7 @@
2
2
  import { client as _heyApiClient } from "./client.gen.js";
3
3
  /**
4
4
  * List all agents
5
- * Returns all AI agents deployed in the workspace. Each agent includes its deployment status, runtime configuration, and global inference endpoint URL.
5
+ * Returns AI agents deployed in the workspace. Each agent includes its deployment status, runtime configuration, and global inference endpoint URL. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array with all agents.
6
6
  */
7
7
  export const listAgents = (options) => {
8
8
  return (options?.client ?? _heyApiClient).get({
@@ -221,7 +221,7 @@ export const verifyCustomDomain = (options) => {
221
221
  };
222
222
  /**
223
223
  * List drives
224
- * Returns all drives in the workspace. Drives provide persistent storage that can be attached to agents, functions, and sandboxes.
224
+ * Returns all drives in the workspace. Drives provide persistent storage that can be attached to agents, functions, and sandboxes. Starting with API version 2026-04-28, the response wraps items in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array with all drives.
225
225
  */
226
226
  export const listDrives = (options) => {
227
227
  return (options?.client ?? _heyApiClient).get({
@@ -348,6 +348,22 @@ export const listAllEgressGateways = (options) => {
348
348
  ...options
349
349
  });
350
350
  };
351
+ /**
352
+ * Egress gateway sandbox attachments
353
+ * Returns the inverse map (gateway → sandbox names) for the workspace. Used by the egress-IPs UI to render attachment counts without fetching the sandboxes listing full client-side.
354
+ */
355
+ export const getEgressGatewayUsage = (options) => {
356
+ return (options?.client ?? _heyApiClient).get({
357
+ security: [
358
+ {
359
+ scheme: 'bearer',
360
+ type: 'http'
361
+ }
362
+ ],
363
+ url: '/egressgateways/usage',
364
+ ...options
365
+ });
366
+ };
351
367
  /**
352
368
  * List all egress IPs across all VPCs and gateways in the workspace
353
369
  */
@@ -397,7 +413,7 @@ export const testFeatureFlag = (options) => {
397
413
  };
398
414
  /**
399
415
  * List all MCP servers
400
- * Returns all MCP server functions deployed in the workspace. Each function includes its deployment status, transport protocol (websocket or http-stream), and endpoint URL.
416
+ * Returns MCP server functions deployed in the workspace. Each function includes its deployment status, transport protocol (websocket or http-stream), and endpoint URL. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array with all functions.
401
417
  */
402
418
  export const listFunctions = (options) => {
403
419
  return (options?.client ?? _heyApiClient).get({
@@ -805,7 +821,7 @@ export const getIntegrationConnectionModel = (options) => {
805
821
  };
806
822
  /**
807
823
  * List batch jobs
808
- * Returns all batch job definitions in the workspace. Each job can be triggered to run multiple parallel tasks with configurable concurrency and retry settings.
824
+ * Returns batch job definitions in the workspace. Each job can be triggered to run multiple parallel tasks with configurable concurrency and retry settings. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array with all jobs.
809
825
  */
810
826
  export const listJobs = (options) => {
811
827
  return (options?.client ?? _heyApiClient).get({
@@ -893,7 +909,7 @@ export const updateJob = (options) => {
893
909
  };
894
910
  /**
895
911
  * List job executions
896
- * Returns paginated list of executions for a batch job, sorted by creation time. Each execution contains status, task counts, and timing information.
912
+ * Returns executions for a batch job. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep the legacy offset/limit contract and return a bare array.
897
913
  */
898
914
  export const listJobExecutions = (options) => {
899
915
  return (options.client ?? _heyApiClient).get({
@@ -959,6 +975,22 @@ export const getJobExecution = (options) => {
959
975
  ...options
960
976
  });
961
977
  };
978
+ /**
979
+ * List execution tasks
980
+ * Returns one cursor-paginated page of an execution's tasks. Tasks are derived from event history each request; only the in-memory slicing is paginated, the events scan still fetches the whole event log behind the scenes. Available starting with API version 2026-04-28.
981
+ */
982
+ export const listJobExecutionTasks = (options) => {
983
+ return (options.client ?? _heyApiClient).get({
984
+ security: [
985
+ {
986
+ scheme: 'bearer',
987
+ type: 'http'
988
+ }
989
+ ],
990
+ url: '/jobs/{jobId}/executions/{executionId}/tasks',
991
+ ...options
992
+ });
993
+ };
962
994
  /**
963
995
  * List job revisions
964
996
  * Returns revisions for a job by name.
@@ -1009,7 +1041,7 @@ export const listMcpHubDefinitions = (options) => {
1009
1041
  };
1010
1042
  /**
1011
1043
  * List model endpoints
1012
- * Returns all model gateway endpoints configured in the workspace. Each model represents a proxy to an external LLM provider (OpenAI, Anthropic, etc.) with unified access control.
1044
+ * Returns model gateway endpoints configured in the workspace. Each model represents a proxy to an external LLM provider (OpenAI, Anthropic, etc.) with unified access control. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array with all models.
1013
1045
  */
1014
1046
  export const listModels = (options) => {
1015
1047
  return (options?.client ?? _heyApiClient).get({
@@ -1161,7 +1193,7 @@ export const declineImageShare = (options) => {
1161
1193
  };
1162
1194
  /**
1163
1195
  * List governance policies
1164
- * Returns all governance policies in the workspace. Policies control deployment locations, hardware flavors, and token limits for agents, functions, and models.
1196
+ * Returns governance policies in the workspace. Policies control deployment locations, hardware flavors, and token limits for agents, functions, and models. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array with all policies.
1165
1197
  */
1166
1198
  export const listPolicies = (options) => {
1167
1199
  return (options?.client ?? _heyApiClient).get({
@@ -1247,6 +1279,22 @@ export const updatePolicy = (options) => {
1247
1279
  }
1248
1280
  });
1249
1281
  };
1282
+ /**
1283
+ * List resources using a policy
1284
+ * Returns the names of every resource (agent, function, model, sandbox, job) currently referencing the given policy. Replaces the client-side fan-out the policies UI used to do over the listings.
1285
+ */
1286
+ export const getPolicyUsages = (options) => {
1287
+ return (options.client ?? _heyApiClient).get({
1288
+ security: [
1289
+ {
1290
+ scheme: 'bearer',
1291
+ type: 'http'
1292
+ }
1293
+ ],
1294
+ url: '/policies/{policyName}/usages',
1295
+ ...options
1296
+ });
1297
+ };
1250
1298
  /**
1251
1299
  * List public ips
1252
1300
  * Returns a list of all public ips used in Blaxel..
@@ -1281,7 +1329,7 @@ export const listSandboxHubDefinitions = (options) => {
1281
1329
  };
1282
1330
  /**
1283
1331
  * List sandboxes
1284
- * Returns all sandboxes in the workspace. Each sandbox includes its configuration, status, and endpoint URL.
1332
+ * Returns sandboxes in the workspace. Each sandbox includes its configuration, status, and endpoint URL. Terminated sandboxes are hidden by default; pass `showTerminated=true` to include them. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array of all sandboxes.
1285
1333
  */
1286
1334
  export const listSandboxes = (options) => {
1287
1335
  return (options?.client ?? _heyApiClient).get({
@@ -1841,7 +1889,7 @@ export const deleteVolumeTemplateVersion = (options) => {
1841
1889
  };
1842
1890
  /**
1843
1891
  * List persistent volumes
1844
- * Returns all persistent storage volumes in the workspace. Volumes can be attached to sandboxes for durable file storage that persists across sessions and sandbox deletions.
1892
+ * Returns persistent storage volumes in the workspace. Volumes can be attached to sandboxes for durable file storage that persists across sessions and sandbox deletions. Starting with API version 2026-04-28 the response is wrapped in `{data, meta}` and supports cursor pagination via the `cursor` and `limit` query parameters; older versions keep returning a bare array of volumes.
1845
1893
  */
1846
1894
  export const listVolumes = (options) => {
1847
1895
  return (options?.client ?? _heyApiClient).get({
@@ -0,0 +1,69 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { ApiKey } from '../authentication/apikey.js';
3
+ import { MissingCredentials } from '../authentication/credentials.js';
4
+ import { authentication } from '../authentication/index.js';
5
+ import { CredentialsError } from './errors.js';
6
+ import { settings } from './settings.js';
7
+ /**
8
+ * ENG-2698: missing / partial BL_WORKSPACE / BL_API_KEY must fail fast with a
9
+ * clear, actionable error instead of silently sending empty headers and
10
+ * surfacing the server's misleading "workspace is required".
11
+ *
12
+ * `env` reads through to `process.env`, so env state is controlled there.
13
+ */
14
+ describe('credential validation', () => {
15
+ const AUTH_ENV = ['BL_API_KEY', 'BL_WORKSPACE', 'BL_CLIENT_CREDENTIALS'];
16
+ let savedEnv;
17
+ let savedCredentials;
18
+ let savedConfig;
19
+ beforeEach(() => {
20
+ savedEnv = {};
21
+ for (const key of AUTH_ENV) {
22
+ savedEnv[key] = process.env[key];
23
+ delete process.env[key];
24
+ }
25
+ savedCredentials = settings.credentials;
26
+ savedConfig = settings.config;
27
+ settings.config = { proxy: '', apikey: '', workspace: '' };
28
+ });
29
+ afterEach(() => {
30
+ for (const key of AUTH_ENV) {
31
+ if (savedEnv[key] === undefined)
32
+ delete process.env[key];
33
+ else
34
+ process.env[key] = savedEnv[key];
35
+ }
36
+ settings.credentials = savedCredentials;
37
+ settings.config = savedConfig;
38
+ });
39
+ it('API key without workspace throws a clear BL_WORKSPACE error from headers', () => {
40
+ settings.credentials = new ApiKey({ apiKey: 'test-key' });
41
+ expect(() => settings.headers).toThrow(CredentialsError);
42
+ expect(() => settings.headers).toThrow(/BL_WORKSPACE/);
43
+ });
44
+ it('API key without workspace rejects from authenticate()', async () => {
45
+ settings.credentials = new ApiKey({ apiKey: 'test-key' });
46
+ await expect(settings.authenticate()).rejects.toThrow(/BL_WORKSPACE/);
47
+ });
48
+ it('workspace set but no API key names the missing API key', () => {
49
+ process.env.BL_WORKSPACE = 'my-workspace';
50
+ settings.credentials = new MissingCredentials();
51
+ expect(() => settings.headers).toThrow(/API key is missing/);
52
+ });
53
+ it('no credentials at all names both env vars', () => {
54
+ settings.credentials = new MissingCredentials();
55
+ expect(() => settings.headers).toThrow(CredentialsError);
56
+ expect(() => settings.headers).toThrow(/BL_API_KEY and BL_WORKSPACE/);
57
+ });
58
+ it('api key + workspace builds clean headers with no empty values', () => {
59
+ settings.credentials = new ApiKey({ apiKey: 'test-key', workspace: 'my-workspace' });
60
+ const headers = settings.headers;
61
+ expect(headers['x-blaxel-workspace']).toBe('my-workspace');
62
+ expect(headers['x-blaxel-authorization']).toBe('Bearer test-key');
63
+ expect(Object.values(headers)).not.toContain('');
64
+ });
65
+ it('authentication() returns a real ApiKey when BL_API_KEY is set', () => {
66
+ process.env.BL_API_KEY = 'test-key';
67
+ expect(authentication()).toBeInstanceOf(ApiKey);
68
+ });
69
+ });
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Thrown when Blaxel credentials are missing or incomplete.
3
+ *
4
+ * Surfaced eagerly with an actionable message (which env var / login step is
5
+ * missing) instead of silently sending empty workspace/authorization headers
6
+ * and letting the user hit a misleading server-side "workspace is required".
7
+ */
8
+ export class CredentialsError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "CredentialsError";
12
+ }
13
+ }
1
14
  export function handleDynamicImportError(err) {
2
15
  if (err instanceof Error) {
3
16
  // We check if it's module import error and retrieve package name from the error message with a regex
@@ -11,14 +11,21 @@ const sessionsWithListenerBudget = new WeakSet();
11
11
  * When `settings.maxConcurrentH2Requests` is `0`/unset the gate is a no-op
12
12
  * (current default behavior: unlimited concurrency).
13
13
  */
14
- // Safety timeout for a held slot. A request that never resolves (no response,
15
- // no error, no abort) would otherwise hold its slot forever and starve every
16
- // queued request for the same domain. After this window we release the slot
17
- // and let the underlying request continue or reject on its own; we never
18
- // cancel the in-flight request here, we only stop it from blocking the queue.
19
- // Kept deliberately conservative (well above any realistic part-upload time)
20
- // so it does not interfere with legitimately slow but healthy requests.
21
- const H2_SLOT_TIMEOUT_MS = 120_000;
14
+ // Last-resort leak guard for a held slot. The slot is now released on the real
15
+ // stream terminal (end / error / abort / cancel / pre-response reject), which
16
+ // always fires, so this timer is a pure backstop for the one residual case: a
17
+ // request that never opens a stream AND never settles (no response, no error,
18
+ // no abort) it must not pin a slot forever and starve the per-domain queue.
19
+ //
20
+ // When it fires it ONLY frees the queue slot. It NEVER aborts or cancels the
21
+ // underlying request or its response stream: `release` does not touch `req` or
22
+ // the body stream, so a long-lived streaming response keeps flowing. Raised
23
+ // well beyond any legitimate stream lifetime so it cannot interfere with a
24
+ // healthy long-open stream (process.streamLogs / execWithStreaming / port
25
+ // proxy). RESIDUAL: a stream that outlives this backstop would have its slot
26
+ // freed early (the queue could then admit one extra stream); acceptable as a
27
+ // pure backstop, since the slot is otherwise tied to the true stream terminal.
28
+ const H2_SLOT_TIMEOUT_MS = 1_800_000; // 30 minutes
22
29
  const h2GatesByDomain = new Map();
23
30
  function getH2Gate(domain) {
24
31
  let gate = h2GatesByDomain.get(domain);
@@ -29,10 +36,14 @@ function getH2Gate(domain) {
29
36
  return gate;
30
37
  }
31
38
  /**
32
- * Acquire an in-flight slot for `domain`. Resolves with a release function
33
- * that is idempotent and FIFO-fair: releasing wakes the longest-waiting
34
- * queued caller for the same domain. A safety timer also releases the slot
35
- * if the caller never does, preventing per-domain starvation.
39
+ * Acquire an OPEN-STREAM slot for `domain`. Resolves with a release function
40
+ * that is idempotent and FIFO-fair: releasing wakes the longest-waiting queued
41
+ * caller for the same domain. The slot is held for the lifetime of an OPEN H2
42
+ * stream the send path releases it on the stream terminal — so the gate
43
+ * bounds true concurrent streams on the one shared session, not merely requests
44
+ * awaiting headers (ENG-2678). A backstop timer releases the slot if a request
45
+ * neither opens a stream nor ever settles, preventing per-domain starvation;
46
+ * it never aborts the underlying request (see `H2_SLOT_TIMEOUT_MS`).
36
47
  */
37
48
  async function acquireH2Slot(domain) {
38
49
  const max = settings.maxConcurrentH2Requests; // 0/undefined = unlimited
@@ -86,66 +97,89 @@ export function createH2Fetch(session) {
86
97
  };
87
98
  }
88
99
  /**
89
- * Creates a fetch()-compatible function backed by the H2 session pool.
100
+ * The single HTTP/2 request gateway (ENG-2679).
90
101
  *
91
- * The pool validates idle sessions before reuse. If no usable H2 session is
92
- * available, the request falls back to regular fetch before any H2 frames
93
- * are sent.
102
+ * Every pool-backed request funnels through here, no matter which entry point
103
+ * it came from: the generated client's fetch (`createPoolBackedH2Fetch`),
104
+ * `SandboxAction.h2Fetch`, and the interpreter's direct path (both via
105
+ * `h2RequestDirectFromPool`). It owns the shared request lifecycle in one place:
106
+ * 1. take a per-domain open-stream slot,
107
+ * 2. get a live session from the pool,
108
+ * 3. send the request on it,
109
+ * 4. evict the session if the send fails after a stream was opened,
110
+ * 5. fall back to globalThis.fetch when the pool has no usable session.
111
+ *
112
+ * This is the chokepoint where reliability behavior that must protect EVERY
113
+ * consumer belongs: the open-stream concurrency limit today, and retry,
114
+ * timeouts, typed errors, and observability in later phases. Adding it once here
115
+ * (instead of re-implementing it per entry point) is what stops the recurring
116
+ * "fixed on one path, still broken on another" regressions.
117
+ *
118
+ * `send` performs the actual wire send on a live session; the caller supplies it
119
+ * so the gateway stays agnostic to the `Request` vs `(url, init)` call shapes,
120
+ * and it receives the slot-release and request-created hooks. `fallback` runs
121
+ * only when the pool has no usable session (a fresh connection, no shared
122
+ * stream is opened).
94
123
  */
95
- export function createPoolBackedH2Fetch(pool, domain) {
96
- return async (input) => {
97
- const rel = await acquireH2Slot(domain);
98
- try {
99
- const session = await pool.get(domain);
100
- if (session) {
101
- let h2RequestCreated = false;
102
- try {
103
- return await _h2Request(session, input, {
104
- onH2RequestCreated: () => {
105
- h2RequestCreated = true;
106
- },
107
- });
108
- }
109
- catch (err) {
110
- if (h2RequestCreated) {
111
- pool.evictSession(domain, session);
112
- }
113
- throw err;
114
- }
115
- }
116
- return await globalThis.fetch(input);
117
- }
118
- finally {
119
- rel();
120
- }
121
- };
122
- }
123
- export async function h2RequestDirectFromPool(pool, domain, url, init) {
124
+ async function h2GatewayRequest(pool, domain, send, fallback) {
125
+ // Take the slot here, but hand its release to the send path: it is held for
126
+ // the OPEN-STREAM lifetime and freed on the stream terminal (ENG-2678). A
127
+ // request that never opens a stream on the shared session (the fallback goes
128
+ // over a different connection) releases it immediately so it does not count
129
+ // against the open-stream budget. `rel` is idempotent, so releasing here when
130
+ // the send path may also release is safe.
124
131
  const rel = await acquireH2Slot(domain);
125
132
  try {
126
133
  const session = await pool.get(domain);
127
134
  if (session) {
128
135
  let h2RequestCreated = false;
129
136
  try {
130
- return await h2RequestDirectInternal(session, url, init, {
137
+ return await send(session, {
131
138
  onH2RequestCreated: () => {
132
139
  h2RequestCreated = true;
133
140
  },
141
+ releaseSlot: rel,
134
142
  });
135
143
  }
136
144
  catch (err) {
145
+ // A failure AFTER a stream opened means the session is suspect: drop it
146
+ // so the next caller gets a fresh one.
137
147
  if (h2RequestCreated) {
138
148
  pool.evictSession(domain, session);
139
149
  }
140
150
  throw err;
141
151
  }
142
152
  }
143
- return await globalThis.fetch(url, init);
153
+ // No usable session: free the slot before falling back over a different
154
+ // connection (no stream opens on the shared session).
155
+ rel();
156
+ return await fallback();
144
157
  }
145
- finally {
158
+ catch (err) {
159
+ // Pre-send throw (pool.get(), Request body read, or the fallback itself):
160
+ // release the slot so a failed request never pins it. Idempotent.
146
161
  rel();
162
+ throw err;
147
163
  }
148
164
  }
165
+ /**
166
+ * Creates a fetch()-compatible function backed by the H2 session pool, routed
167
+ * through the single gateway. Used as the generated client's `fetch`.
168
+ *
169
+ * If no usable H2 session is available, the request falls back to regular fetch
170
+ * before any H2 frames are sent.
171
+ */
172
+ export function createPoolBackedH2Fetch(pool, domain) {
173
+ return (input) => h2GatewayRequest(pool, domain, (session, options) => _h2Request(session, input, options), () => globalThis.fetch(input));
174
+ }
175
+ /**
176
+ * Pool-backed H2 request taking raw url + init (skips Request allocation),
177
+ * routed through the same single gateway. Used by `SandboxAction.h2Fetch` and
178
+ * the code interpreter.
179
+ */
180
+ export function h2RequestDirectFromPool(pool, domain, url, init) {
181
+ return h2GatewayRequest(pool, domain, (session, options) => h2RequestDirectInternal(session, url, init, options), () => globalThis.fetch(url, init));
182
+ }
149
183
  /**
150
184
  * Low-level H2 request that takes raw URL + init, skipping Request construction.
151
185
  * Used by SandboxAction.h2Fetch() for direct calls from subsystems.
@@ -155,6 +189,9 @@ export function h2RequestDirect(session, url, init) {
155
189
  }
156
190
  function h2RequestDirectInternal(session, url, init, options) {
157
191
  if (session.closed || session.destroyed) {
192
+ // Pre-flight fallback (session unusable): no stream opens on the shared
193
+ // session, so free any held slot before going over globalThis.fetch.
194
+ options?.releaseSlot?.();
158
195
  return globalThis.fetch(url, init);
159
196
  }
160
197
  const parsed = new URL(url);
@@ -194,7 +231,9 @@ function h2RequestDirectInternal(session, url, init, options) {
194
231
  else {
195
232
  // FormData, ReadableStream, Blob, etc. can't be serialized to Buffer
196
233
  // for manual H2 framing — fall back to regular fetch (pre-flight,
197
- // nothing has been sent on the wire yet).
234
+ // nothing has been sent on the wire yet). No stream opens on the shared
235
+ // session, so free any held slot before falling back.
236
+ options?.releaseSlot?.();
198
237
  return globalThis.fetch(url, init);
199
238
  }
200
239
  if (!h2Headers["content-length"]) {
@@ -238,13 +277,19 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
238
277
  let streamClosed = false;
239
278
  let req = null;
240
279
  let releaseSessionRef = () => { };
280
+ // The per-domain open-stream slot (idempotent; no-op for the non-pool
281
+ // transports). Held for the OPEN-STREAM lifetime and released alongside the
282
+ // session ref from cleanupActiveRequest() on every terminal path.
283
+ const releaseSlot = options?.releaseSlot ?? (() => { });
241
284
  let abort = null;
242
285
  try {
243
286
  req = session.request(h2Headers);
244
287
  }
245
288
  catch {
246
289
  // Pre-flight fallback: session.request() threw synchronously, so no
247
- // H2 frames were sent. Safe to retry over globalThis.fetch.
290
+ // H2 frames were sent. No stream opened on the shared session, so free
291
+ // the slot before retrying over globalThis.fetch.
292
+ releaseSlot();
248
293
  globalThis.fetch(fallbackUrl, fallbackInit).then(resolve, reject);
249
294
  return;
250
295
  }
@@ -259,7 +304,13 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
259
304
  const cleanupActiveRequest = () => {
260
305
  if (abort)
261
306
  signal?.removeEventListener("abort", abort);
307
+ // Slot and session-ref share the OPEN-STREAM lifetime but stay
308
+ // independent and each idempotent (PM-2160). Every terminal path funnels
309
+ // through here exactly once: pre-response reject (close/goaway/error),
310
+ // abort-before-response, abort-during-stream, stream end, stream error,
311
+ // and ReadableStream cancel — so the slot is freed once on each.
262
312
  releaseSessionRef();
313
+ releaseSlot();
263
314
  };
264
315
  const rejectBeforeResponse = (err) => {
265
316
  if (settled)
@@ -117,6 +117,15 @@ export class H2Pool {
117
117
  return session;
118
118
  }
119
119
  if (await this.ping(session)) {
120
+ // ENG-2676 generation/identity pin: `await this.ping` yields, and during
121
+ // that await an eviction listener (goaway/error/close ->
122
+ // attachEvictionListeners, see above) may have deleted or replaced this
123
+ // entry. `entry` is the exact object held in the map, so if it is no
124
+ // longer the cached generation, refuse the now-stale session instead of
125
+ // handing back a zombie — the ENG-2422 failure re-entering through the
126
+ // validate race. The caller falls through to establish a fresh session.
127
+ if (this.sessions.get(domain) !== entry)
128
+ return null;
120
129
  this.markUsed(domain, session);
121
130
  return session;
122
131
  }
@@ -0,0 +1,83 @@
1
+ export function unwrapListData(response) {
2
+ if (!response) {
3
+ return [];
4
+ }
5
+ if (Array.isArray(response)) {
6
+ return response;
7
+ }
8
+ return response.data ?? [];
9
+ }
10
+ function unwrapListMeta(response) {
11
+ if (!response || Array.isArray(response)) {
12
+ return { hasMore: false };
13
+ }
14
+ return response.meta ?? { hasMore: false };
15
+ }
16
+ export async function createPaginatedList({ response, fetchPage, mapItem, query, seenCursors, }) {
17
+ const meta = unwrapListMeta(response);
18
+ const data = await Promise.all(unwrapListData(response).map(mapItem));
19
+ const cursors = new Set(seenCursors);
20
+ if (query?.cursor) {
21
+ cursors.add(query.cursor);
22
+ }
23
+ const list = {
24
+ data,
25
+ meta,
26
+ get hasMore() {
27
+ return Boolean(meta.hasMore);
28
+ },
29
+ get nextCursor() {
30
+ return meta.nextCursor || undefined;
31
+ },
32
+ async nextPage() {
33
+ const cursor = list.nextCursor;
34
+ if (!cursor) {
35
+ return null;
36
+ }
37
+ if (cursors.has(cursor)) {
38
+ throw new Error("Pagination returned a repeated cursor");
39
+ }
40
+ const nextQuery = { ...(query ?? {}), cursor };
41
+ const nextSeenCursors = new Set(cursors);
42
+ nextSeenCursors.add(cursor);
43
+ return createPaginatedList({
44
+ response: await fetchPage(nextQuery),
45
+ fetchPage,
46
+ mapItem,
47
+ query: nextQuery,
48
+ seenCursors: nextSeenCursors,
49
+ });
50
+ },
51
+ async autoPagingEach(onItem) {
52
+ for await (const item of list) {
53
+ const shouldContinue = await onItem(item);
54
+ if (shouldContinue === false) {
55
+ return;
56
+ }
57
+ }
58
+ },
59
+ async autoPagingToArray(options) {
60
+ if (!options || !Number.isFinite(options.limit) || options.limit <= 0) {
61
+ throw new Error("autoPagingToArray requires a positive limit");
62
+ }
63
+ const items = [];
64
+ for await (const item of list) {
65
+ items.push(item);
66
+ if (items.length >= options.limit) {
67
+ return items;
68
+ }
69
+ }
70
+ return items;
71
+ },
72
+ async *[Symbol.asyncIterator]() {
73
+ let page = list;
74
+ while (page) {
75
+ for (const item of page.data) {
76
+ yield item;
77
+ }
78
+ page = await page.nextPage();
79
+ }
80
+ },
81
+ };
82
+ return list;
83
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createPaginatedList, unwrapListData } from "./pagination.js";
3
+ describe("pagination helpers", () => {
4
+ it("unwraps legacy array and paginated list responses", () => {
5
+ expect(unwrapListData([{ name: "legacy" }])).toEqual([{ name: "legacy" }]);
6
+ expect(unwrapListData({
7
+ data: [{ name: "paginated" }],
8
+ meta: { hasMore: false },
9
+ })).toEqual([{ name: "paginated" }]);
10
+ });
11
+ it("returns page data and lets callers request the next page", async () => {
12
+ const cursors = [];
13
+ const fetchPage = async (query) => {
14
+ await Promise.resolve();
15
+ cursors.push(query?.cursor);
16
+ if (!query?.cursor) {
17
+ return {
18
+ data: ["first"],
19
+ meta: { hasMore: true, nextCursor: "next-page" },
20
+ };
21
+ }
22
+ return {
23
+ data: ["second"],
24
+ meta: { hasMore: false },
25
+ };
26
+ };
27
+ const page = await createPaginatedList({
28
+ response: await fetchPage(),
29
+ fetchPage,
30
+ mapItem: (item) => item.toUpperCase(),
31
+ });
32
+ expect(page.data).toEqual(["FIRST"]);
33
+ expect(page.hasMore).toBe(true);
34
+ expect(page.nextCursor).toBe("next-page");
35
+ const nextPage = await page.nextPage();
36
+ expect(nextPage?.data).toEqual(["SECOND"]);
37
+ expect(cursors).toEqual([undefined, "next-page"]);
38
+ });
39
+ it("supports auto paging with an explicit limit", async () => {
40
+ const fetchPage = async (query) => {
41
+ await Promise.resolve();
42
+ if (!query?.cursor) {
43
+ return {
44
+ data: ["first"],
45
+ meta: { hasMore: true, nextCursor: "next-page" },
46
+ };
47
+ }
48
+ return {
49
+ data: ["second"],
50
+ meta: { hasMore: false },
51
+ };
52
+ };
53
+ const page = await createPaginatedList({
54
+ response: await fetchPage(),
55
+ fetchPage,
56
+ mapItem: (item) => item,
57
+ });
58
+ await expect(page.autoPagingToArray({ limit: 2 })).resolves.toEqual(["first", "second"]);
59
+ });
60
+ });