@mastra/docker 0.9.0-alpha.1 → 0.9.0-alpha.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/README.md CHANGED
@@ -91,6 +91,11 @@ const b = new DockerSandbox({ template });
91
91
  const result = await template.build();
92
92
  if (result.status !== 'ready') throw new Error(result.error);
93
93
 
94
+ // Cancel repository resolution or template preparation when needed.
95
+ const controller = new AbortController();
96
+ await a.start({ abortSignal: controller.signal });
97
+ // Explicit builds accept the same option: template.build({ abortSignal: controller.signal }).
98
+
94
99
  // Remove the built image when done (independent of any sandbox's destroy()).
95
100
  await template.dispose();
96
101
  ```
@@ -105,6 +110,9 @@ template, and their signatures match the E2B and platform template builders.
105
110
  The image tag is content-addressed (`mastra-template:<hash>`), so `build()` is
106
111
  idempotent and reuses an existing image unless you pass `{ force: true }`,
107
112
  which also bypasses the daemon's layer cache so every step really re-runs.
113
+ Concurrent callers share preparation work, but each caller can cancel its own
114
+ wait independently; the underlying build is cancelled only after its last
115
+ active caller aborts.
108
116
 
109
117
  Never put secrets in `setEnvs` — they are baked into the image. For a step that
110
118
  needs a credential, use `runWithSecrets`: the command runs in a throwaway build
@@ -0,0 +1,7 @@
1
+ import { SandboxAbortError } from '@mastra/core/workspace';
2
+ export declare function createAbortError(signal: AbortSignal, operation: string): SandboxAbortError;
3
+ export declare function throwIfAborted(signal: AbortSignal | undefined, operation: string): void;
4
+ export declare function isAbortError(error: unknown): boolean;
5
+ export declare function normalizeAbortError(error: unknown, operation: string): unknown;
6
+ export declare function waitForAbortable<T>(promise: Promise<T>, signal: AbortSignal | undefined, operation: string, onAbort?: () => void): Promise<T>;
7
+ //# sourceMappingURL=abort.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abort.d.ts","sourceRoot":"","sources":["../src/abort.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE3D,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,iBAAiB,CAE1F;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAEvF;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAKpD;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAM9E;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,MAAM,IAAI,GACnB,OAAO,CAAC,CAAC,CAAC,CAqBZ"}
package/dist/index.cjs CHANGED
@@ -32,6 +32,38 @@ let path_posix = require("path/posix");
32
32
  path_posix = __toESM(path_posix, 1);
33
33
  let _grpc_grpc_js = require("@grpc/grpc-js");
34
34
  let child_process = require("child_process");
35
+ //#region src/abort.ts
36
+ function createAbortError(signal, operation) {
37
+ return new _mastra_core_workspace.SandboxAbortError(operation, signal.reason);
38
+ }
39
+ function throwIfAborted(signal, operation) {
40
+ if (signal?.aborted) throw createAbortError(signal, operation);
41
+ }
42
+ function isAbortError(error) {
43
+ return error instanceof _mastra_core_workspace.SandboxAbortError || error instanceof Error && (error.name === "AbortError" || "code" in error && error.code === "ABORT_ERR");
44
+ }
45
+ function normalizeAbortError(error, operation) {
46
+ return error instanceof _mastra_core_workspace.SandboxAbortError ? error : isAbortError(error) ? new _mastra_core_workspace.SandboxAbortError(operation, error) : error;
47
+ }
48
+ function waitForAbortable(promise, signal, operation, onAbort) {
49
+ throwIfAborted(signal, operation);
50
+ if (!signal) return promise;
51
+ return new Promise((resolve, reject) => {
52
+ const abort = () => {
53
+ onAbort?.();
54
+ reject(new _mastra_core_workspace.SandboxAbortError(operation, signal.reason));
55
+ };
56
+ signal.addEventListener("abort", abort, { once: true });
57
+ promise.then((value) => {
58
+ signal.removeEventListener("abort", abort);
59
+ resolve(value);
60
+ }, (error) => {
61
+ signal.removeEventListener("abort", abort);
62
+ reject(normalizeAbortError(error, operation));
63
+ });
64
+ });
65
+ }
66
+ //#endregion
35
67
  //#region src/sandbox/process-manager.ts
36
68
  /**
37
69
  * Docker Process Manager
@@ -569,7 +601,9 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
569
601
  if (!this._container) throw new _mastra_core_workspace.SandboxNotReadyError(this.id);
570
602
  return this._container;
571
603
  }
572
- async start() {
604
+ async start(options = {}) {
605
+ const { abortSignal } = options;
606
+ throwIfAborted(abortSignal, "start Docker sandbox");
573
607
  this.logger.debug(`${LOG_PREFIX} Starting sandbox ${this.id}...`);
574
608
  const existing = await this._findExistingContainer();
575
609
  if (existing) {
@@ -590,7 +624,7 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
590
624
  return;
591
625
  }
592
626
  this._warnOnPrivilegedHardeningConflict(this._privileged);
593
- await this._resolveTemplate();
627
+ await this._resolveTemplate(abortSignal);
594
628
  await this._ensureImage();
595
629
  const envArray = Object.entries(this._env).map(([k, v]) => `${k}=${v}`);
596
630
  const binds = Object.entries(this._volumes).map(([host, container]) => `${host}:${container}`);
@@ -850,10 +884,20 @@ var DockerSandbox = class DockerSandbox extends _mastra_core_workspace.MastraSan
850
884
  * creates a container, so a resolver-form template re-resolves each time.
851
885
  * Adopts the template's workdir unless the sandbox was given one explicitly.
852
886
  */
853
- async _resolveTemplate() {
887
+ async _resolveTemplate(abortSignal) {
854
888
  if (!this._templateSpec) return;
855
- const template = typeof this._templateSpec === "function" ? await this._templateSpec() : this._templateSpec;
856
- const result = await template.build({ docker: this._docker });
889
+ throwIfAborted(abortSignal, "start Docker sandbox");
890
+ let template;
891
+ try {
892
+ template = typeof this._templateSpec === "function" ? await this._templateSpec({ abortSignal }) : this._templateSpec;
893
+ } catch (error) {
894
+ throw normalizeAbortError(error, "start Docker sandbox");
895
+ }
896
+ throwIfAborted(abortSignal, "start Docker sandbox");
897
+ const result = await template.build({
898
+ docker: this._docker,
899
+ abortSignal
900
+ });
857
901
  if (result.status !== "ready") throw new _mastra_core_workspace.SandboxError(`Docker template build failed: ${result.error ?? "unknown error"}`, "START_FAILED", {
858
902
  templateId: result.templateId,
859
903
  reason: "template_build_failed"
@@ -1226,9 +1270,17 @@ const AUTH_CREDENTIALS_METHOD = "/moby.filesync.v1.Auth/Credentials";
1226
1270
  * Open a session that answers `GetSecret` from `secrets`. The returned id must
1227
1271
  * be sent as the `session` query parameter of the build request.
1228
1272
  */
1229
- function openBuildSession(docker, secrets) {
1273
+ function openBuildSession(docker, secrets, abortSignal) {
1274
+ throwIfAborted(abortSignal, "build Docker template");
1230
1275
  const id = (0, crypto.randomUUID)();
1231
1276
  return new Promise((resolve, reject) => {
1277
+ let settled = false;
1278
+ const abort = () => {
1279
+ if (settled || !abortSignal) return;
1280
+ settled = true;
1281
+ reject(createAbortError(abortSignal, "build Docker template"));
1282
+ };
1283
+ abortSignal?.addEventListener("abort", abort, { once: true });
1232
1284
  docker.modem.dial({
1233
1285
  method: "POST",
1234
1286
  path: "/session",
@@ -1244,8 +1296,14 @@ function openBuildSession(docker, secrets) {
1244
1296
  500: "server error"
1245
1297
  }
1246
1298
  }, (err, socket) => {
1299
+ if (settled) {
1300
+ socket?.end?.();
1301
+ return;
1302
+ }
1303
+ settled = true;
1304
+ abortSignal?.removeEventListener("abort", abort);
1247
1305
  if (err) {
1248
- reject(err);
1306
+ reject(abortSignal?.aborted ? createAbortError(abortSignal, "build Docker template") : err);
1249
1307
  return;
1250
1308
  }
1251
1309
  const server = new _grpc_grpc_js.Server();
@@ -1566,7 +1624,8 @@ var DockerTemplate = class DockerTemplate {
1566
1624
  #secrets;
1567
1625
  #docker;
1568
1626
  #built = false;
1569
- #inFlight;
1627
+ #queueTail;
1628
+ #plainBuild;
1570
1629
  constructor(options = {}, state) {
1571
1630
  this.#baseImage = state ? state.baseImage : validateString(options.baseImage ?? "node:22-slim", "baseImage");
1572
1631
  this.#operations = state?.operations ?? [];
@@ -1713,19 +1772,49 @@ var DockerTemplate = class DockerTemplate {
1713
1772
  * `options.secrets`, the template's `secrets` option, or `process.env`.
1714
1773
  */
1715
1774
  async build(options = {}) {
1716
- if (!options.force && options.secrets === void 0 && options.docker === void 0 && this.#inFlight) return this.#inFlight;
1717
- const run = (this.#inFlight?.catch(() => void 0) ?? Promise.resolve()).then(() => this.#build(options));
1718
- this.#inFlight = run;
1719
- run.finally(() => {
1720
- if (this.#inFlight === run) this.#inFlight = void 0;
1775
+ throwIfAborted(options.abortSignal, "build Docker template");
1776
+ const isPlain = !options.force && options.secrets === void 0 && options.docker === void 0;
1777
+ if (isPlain && this.#plainBuild) return this.#waitForBuild(this.#plainBuild, options.abortSignal);
1778
+ const previous = this.#queueTail?.promise.catch(() => void 0) ?? Promise.resolve();
1779
+ const controller = new AbortController();
1780
+ const run = {
1781
+ controller,
1782
+ waiters: 0,
1783
+ promise: previous.then(() => {
1784
+ throwIfAborted(controller.signal, "build Docker template");
1785
+ return this.#build({
1786
+ ...options,
1787
+ abortSignal: controller.signal
1788
+ });
1789
+ })
1790
+ };
1791
+ this.#queueTail = run;
1792
+ if (isPlain) this.#plainBuild = run;
1793
+ run.promise.finally(() => {
1794
+ if (this.#queueTail === run) this.#queueTail = void 0;
1795
+ if (this.#plainBuild === run) this.#plainBuild = void 0;
1721
1796
  }).catch(() => void 0);
1722
- return run;
1797
+ return this.#waitForBuild(run, options.abortSignal);
1798
+ }
1799
+ #waitForBuild(run, abortSignal) {
1800
+ run.waiters += 1;
1801
+ let settled = false;
1802
+ const release = (aborted) => {
1803
+ if (settled) return;
1804
+ settled = true;
1805
+ run.waiters -= 1;
1806
+ if (aborted && run.waiters === 0) run.controller.abort();
1807
+ };
1808
+ return waitForAbortable(run.promise, abortSignal, "build Docker template", () => release(true)).finally(() => release(false));
1723
1809
  }
1724
1810
  async #build(options) {
1725
1811
  const docker = options.docker ?? this.#getDocker();
1726
1812
  const tag = this.templateId;
1813
+ const { abortSignal } = options;
1814
+ throwIfAborted(abortSignal, "build Docker template");
1727
1815
  if (!options.force) try {
1728
1816
  await docker.getImage(tag).inspect();
1817
+ throwIfAborted(abortSignal, "build Docker template");
1729
1818
  this.#built = true;
1730
1819
  return {
1731
1820
  status: "ready",
@@ -1735,24 +1824,37 @@ var DockerTemplate = class DockerTemplate {
1735
1824
  if (!isImageNotFoundError(error)) throw error;
1736
1825
  }
1737
1826
  const secrets = await this.#resolveSecrets(options.secrets);
1827
+ throwIfAborted(abortSignal, "build Docker template");
1738
1828
  const context = (0, tar_stream.pack)();
1739
1829
  context.entry({ name: "Dockerfile" }, this.dockerfile);
1740
1830
  context.finalize();
1741
1831
  try {
1742
1832
  const nocache = options.force === true;
1743
1833
  if (secrets) {
1744
- const { stream, session } = await this.#buildWithSecrets(docker, context, tag, secrets, nocache);
1834
+ const { stream, session } = await this.#buildWithSecrets(docker, context, tag, secrets, nocache, abortSignal);
1745
1835
  try {
1746
- await this.#followBuild(docker, stream);
1836
+ await this.#followBuild(docker, stream, abortSignal);
1747
1837
  } finally {
1748
1838
  session.close();
1749
1839
  }
1750
- } else await this.#followBuild(docker, await docker.buildImage(context, {
1751
- t: tag,
1752
- nocache
1753
- }));
1840
+ } else {
1841
+ const stream = await waitForAbortable(docker.buildImage(context, {
1842
+ t: tag,
1843
+ nocache
1844
+ }).then((stream) => {
1845
+ if (abortSignal?.aborted) {
1846
+ stream.destroy?.();
1847
+ throw createAbortError(abortSignal, "build Docker template");
1848
+ }
1849
+ return stream;
1850
+ }), abortSignal, "build Docker template", () => {
1851
+ context.destroy?.();
1852
+ });
1853
+ await this.#followBuild(docker, stream, abortSignal);
1854
+ }
1754
1855
  } catch (error) {
1755
1856
  this.#built = false;
1857
+ if (abortSignal?.aborted) throw normalizeAbortError(error, "build Docker template");
1756
1858
  return {
1757
1859
  status: "failed",
1758
1860
  templateId: tag,
@@ -1783,12 +1885,35 @@ var DockerTemplate = class DockerTemplate {
1783
1885
  * directly because `docker.buildImage` replaces any session id with its own
1784
1886
  * auth-only session when `version` is `'2'`.
1785
1887
  */
1786
- async #buildWithSecrets(docker, context, tag, secrets, nocache) {
1787
- const session = await openBuildSession(docker, secrets);
1888
+ async #buildWithSecrets(docker, context, tag, secrets, nocache, abortSignal) {
1889
+ throwIfAborted(abortSignal, "build Docker template");
1890
+ const session = await openBuildSession(docker, secrets, abortSignal);
1788
1891
  let stream;
1789
1892
  try {
1790
1893
  stream = await new Promise((resolve, reject) => {
1791
- docker.modem.dial({
1894
+ let settled = false;
1895
+ let request;
1896
+ const finish = (error, data) => {
1897
+ if (settled) {
1898
+ if (data) data.destroy?.();
1899
+ return;
1900
+ }
1901
+ settled = true;
1902
+ abortSignal?.removeEventListener("abort", abort);
1903
+ error === void 0 ? resolve(data) : reject(error);
1904
+ };
1905
+ const abort = () => {
1906
+ context.destroy?.();
1907
+ request?.destroy?.();
1908
+ session.close();
1909
+ if (abortSignal) finish(createAbortError(abortSignal, "build Docker template"));
1910
+ };
1911
+ abortSignal?.addEventListener("abort", abort, { once: true });
1912
+ if (abortSignal?.aborted) {
1913
+ abort();
1914
+ return;
1915
+ }
1916
+ request = docker.modem.dial({
1792
1917
  path: "/build?",
1793
1918
  method: "POST",
1794
1919
  file: context,
@@ -1803,7 +1928,7 @@ var DockerTemplate = class DockerTemplate {
1803
1928
  200: true,
1804
1929
  500: "server error"
1805
1930
  }
1806
- }, (err, data) => err ? reject(err) : resolve(data));
1931
+ }, (err, data) => finish(err ?? void 0, data));
1807
1932
  });
1808
1933
  } catch (error) {
1809
1934
  session.close();
@@ -1814,20 +1939,36 @@ var DockerTemplate = class DockerTemplate {
1814
1939
  session
1815
1940
  };
1816
1941
  }
1817
- #followBuild(docker, stream) {
1942
+ #followBuild(docker, stream, abortSignal) {
1818
1943
  return new Promise((resolve, reject) => {
1944
+ let settled = false;
1945
+ const finish = (error) => {
1946
+ if (settled) return;
1947
+ settled = true;
1948
+ abortSignal?.removeEventListener("abort", abort);
1949
+ error === void 0 ? resolve() : reject(error);
1950
+ };
1951
+ const abort = () => {
1952
+ stream.destroy?.();
1953
+ if (abortSignal) finish(createAbortError(abortSignal, "build Docker template"));
1954
+ };
1955
+ abortSignal?.addEventListener("abort", abort, { once: true });
1956
+ if (abortSignal?.aborted) {
1957
+ abort();
1958
+ return;
1959
+ }
1819
1960
  docker.modem.followProgress(stream, (err, output) => {
1820
1961
  if (err) {
1821
- reject(err);
1962
+ finish(err);
1822
1963
  return;
1823
1964
  }
1824
1965
  const failure = output?.find((entry) => entry && (entry.error !== void 0 || entry.errorDetail !== void 0));
1825
1966
  if (failure) {
1826
1967
  const detail = failure.errorDetail;
1827
- reject(new Error(String(detail?.message ?? failure.error ?? "docker build failed")));
1968
+ finish(new Error(String(detail?.message ?? failure.error ?? "docker build failed")));
1828
1969
  return;
1829
1970
  }
1830
- resolve();
1971
+ finish();
1831
1972
  });
1832
1973
  });
1833
1974
  }
@@ -1976,16 +2117,31 @@ function createDockerRepoTemplate(options) {
1976
2117
  if (options.ref !== void 0 && !REF_PATTERN.test(options.ref)) throw new Error(`Invalid ref '${options.ref}': expected a git ref name`);
1977
2118
  const workingDirectory = trimTrailingSlashes(options.workingDirectory ?? DEFAULT_WORKING_DIRECTORY);
1978
2119
  if (!workingDirectory.startsWith("/")) throw new Error(`workingDirectory must be an absolute path, got '${options.workingDirectory}'`);
1979
- return () => resolveRepoTemplate(options, workingDirectory);
2120
+ return (resolveOptions) => resolveRepoTemplate(options, workingDirectory, resolveOptions);
1980
2121
  }
1981
- async function resolveRepoTemplate(options, workingDirectory) {
1982
- const access = await options.getRepositoryAccess();
2122
+ async function resolveRepoTemplate(options, workingDirectory, resolveOptions = {}) {
2123
+ const { abortSignal } = resolveOptions;
2124
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2125
+ let access;
2126
+ try {
2127
+ access = await options.getRepositoryAccess(resolveOptions);
2128
+ } catch (error) {
2129
+ throw normalizeAbortError(error, "resolve Docker repository template");
2130
+ }
2131
+ throwIfAborted(abortSignal, "resolve Docker repository template");
1983
2132
  const cloneUrl = access?.cloneUrl;
1984
2133
  if (!cloneUrl) throw new Error("Repo template has no clone URL: repository access returned none.");
1985
2134
  assertCloneUrl(cloneUrl);
1986
2135
  const token = access?.authorization?.token;
1987
- const buildEnv = typeof options.buildEnv === "function" ? await options.buildEnv() : options.buildEnv;
1988
- const sha = await resolveHead(cloneUrl, options.ref, token);
2136
+ let buildEnv;
2137
+ try {
2138
+ buildEnv = typeof options.buildEnv === "function" ? await options.buildEnv(resolveOptions) : options.buildEnv;
2139
+ } catch (error) {
2140
+ throw normalizeAbortError(error, "resolve Docker repository template");
2141
+ }
2142
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2143
+ const sha = await resolveHead(cloneUrl, options.ref, token, abortSignal);
2144
+ throwIfAborted(abortSignal, "resolve Docker repository template");
1989
2145
  if (!sha) throw new Error(`Could not resolve ${options.ref ?? "HEAD"} of ${cloneUrl} with git ls-remote; check the ref, the credential and network access`);
1990
2146
  return buildRepoTemplate({
1991
2147
  cloneUrl,
@@ -2034,7 +2190,8 @@ function buildRepoTemplate(inputs) {
2034
2190
  * yields undefined; the caller decides how to surface it.
2035
2191
  * @internal exported for tests.
2036
2192
  */
2037
- async function resolveHead(cloneUrl, ref, token) {
2193
+ async function resolveHead(cloneUrl, ref, token, abortSignal) {
2194
+ throwIfAborted(abortSignal, "resolve Docker repository template");
2038
2195
  if (ref && FULL_SHA_PATTERN.test(ref)) return ref.toLowerCase();
2039
2196
  try {
2040
2197
  const authEnv = token ? {
@@ -2049,6 +2206,7 @@ async function resolveHead(cloneUrl, ref, token) {
2049
2206
  ref ?? "HEAD"
2050
2207
  ], {
2051
2208
  timeout: 1e4,
2209
+ signal: abortSignal,
2052
2210
  env: {
2053
2211
  ...process.env,
2054
2212
  ...authEnv,
@@ -2058,7 +2216,8 @@ async function resolveHead(cloneUrl, ref, token) {
2058
2216
  const lines = stdout.split("\n").filter(Boolean).map((line) => line.split(" "));
2059
2217
  const sha = (lines.find(([, name]) => name?.endsWith("^{}")) ?? lines[0])?.[0]?.trim();
2060
2218
  return sha && SHA_PATTERN.test(sha) ? sha.toLowerCase() : void 0;
2061
- } catch {
2219
+ } catch (error) {
2220
+ if (abortSignal?.aborted) throw normalizeAbortError(error, "resolve Docker repository template");
2062
2221
  return;
2063
2222
  }
2064
2223
  }