@stim-cli/cache-s3 0.0.0 → 1.22.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
1
  # @stim-cli/cache-s3
2
2
 
3
- Placeholder used to configure npm trusted publishing. Install a version published with the other Stim packages.
3
+ This opt-in provider owns the AWS SDK and archive dependencies. Installing
4
+ Stim or `@stim-cli/cache` alone does not install it.
5
+
6
+ If Stim is not installed globally, replace `stim` with `npx stim`.
7
+
8
+ Install the provider in the project:
9
+
10
+ ```bash
11
+ npm install @stim-cli/cache-s3
12
+ ```
13
+
14
+ Then select it in `.stim.json`:
15
+
16
+ ```json
17
+ {
18
+ "cache": {
19
+ "provider": "@stim-cli/cache-s3",
20
+ "options": {
21
+ "bucket": "stim-cache",
22
+ "endpoint": "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com",
23
+ "region": "auto",
24
+ "prefix": "team/cache-v1"
25
+ }
26
+ }
27
+ }
28
+ ```
29
+
30
+ For AWS S3, omit `endpoint` and use the bucket's region. Other S3-compatible
31
+ services can set `forcePathStyle: true` when required. Credentials use the AWS
32
+ SDK's standard credential chain. For a named profile in `~/.aws/credentials`,
33
+ set `cache.options.profile` to `stim-r2`, or run commands with
34
+ `AWS_PROFILE=stim-r2`, for example `AWS_PROFILE=stim-r2 stim ios`. An explicit
35
+ cache profile overrides the ambient AWS profile. Credentials never belong in `.stim.json`.
36
+ Programmatic Stim clients use their captured environment, including profile files
37
+ and `credential_process`. The optional provider resolves credentials and environment-backed request settings
38
+ in a cancellable helper process, keeping the calling process environment unchanged.
39
+ Captured endpoint settings are pinned for the request, with no fallback to the
40
+ calling process's endpoint. Credentials and settings return over private IPC and
41
+ are reused until the credentials need refreshing.
42
+
43
+ Create a private R2 bucket and an R2 API token with Object Read & Write access
44
+ scoped to that bucket. Copy the S3 endpoint shown by Cloudflare; jurisdiction
45
+ specific buckets use their own endpoint. See the
46
+ [R2 S3 setup guide](https://developers.cloudflare.com/r2/get-started/s3/).
47
+
48
+ The provider shares completed native `.app`/`.apk` artifacts and Metro values
49
+ through one file transport. Stim checks its local tier first and promotes build
50
+ hits locally. Build archives preserve file modes and contained framework links;
51
+ downloads verify SHA-256 before becoming reusable. Conditional writes preserve
52
+ existing build entries unless overwrite was requested. Use matching native
53
+ toolchains for the initial test; the provider does not add toolchain information
54
+ to Stim's existing build key.
55
+
56
+ Archive extraction runs in an owned helper. Cancellation waits for that process
57
+ to exit before removing download staging, so extraction cannot keep writing
58
+ after the operation settles. A cleanup failure reports the retained directory
59
+ and filesystem cause; bounded plans refuse instead of counting it as a miss.
60
+
61
+ The file transport is also exported as `createS3FileCache()` from
62
+ `@stim-cli/cache-s3`, with the `FileCacheCapability` interface exported from
63
+ `@stim-cli/cache`. A cache adapter supplies a compatible key and a completed
64
+ file, then owns the downloaded file inside its destination directory. This
65
+ also lets native Gradle artifact caching share the completed APK and verification
66
+ receipt through this transport when `android.artifactInputs` is declared. Private
67
+ input files and environment values stay local. Other Stim home directories are
68
+ not automatically synchronized.
69
+
70
+ Objects use `<prefix>/builds/<platform>/<key>` and
71
+ `<prefix>/metro/<cacheName>/<hex-key>`. Native Gradle APKs use
72
+ `<prefix>/native-android/v1/<key>`. A prefix defaults to `stim/v1`. Keep
73
+ incompatible snapshot formats in separate key namespaces. Retention belongs to
74
+ the bucket lifecycle configuration; local GC never removes shared objects.
75
+ Uploads use a single-object S3 PUT, so entries larger than S3's 5 GiB limit are
76
+ not supported. A connection reset retries the completed file with a fresh stream,
77
+ up to three PUT attempts with short, cancellable delays. Conditional writes still
78
+ preserve an existing entry if the first upload committed but its reply was lost.
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,46 @@
1
+ import { S3Client } from "@aws-sdk/client-s3";
2
+ //#region credentials-worker.ts
3
+ process.once("message", async (input) => {
4
+ const client = new S3Client({
5
+ ...input,
6
+ logger: {
7
+ debug() {},
8
+ info() {},
9
+ warn() {},
10
+ error() {}
11
+ }
12
+ });
13
+ try {
14
+ const config = client.config;
15
+ const credentials = await config.credentials();
16
+ const endpoint = input.endpoint ?? await config.serviceConfiguredEndpoint?.();
17
+ const message = {
18
+ credentials: {
19
+ ...credentials,
20
+ expiration: credentials.expiration?.toISOString()
21
+ },
22
+ requestConfig: {
23
+ ...endpoint === void 0 ? {} : { endpoint },
24
+ disableClockSkewCorrection: await config.disableClockSkewCorrection(),
25
+ useFipsEndpoint: await config.useFipsEndpoint(),
26
+ useDualstackEndpoint: await config.useDualstackEndpoint(),
27
+ useArnRegion: typeof config.useArnRegion === "function" ? await config.useArnRegion() : config.useArnRegion,
28
+ disableS3ExpressSessionAuth: typeof config.disableS3ExpressSessionAuth === "function" ? await config.disableS3ExpressSessionAuth() : config.disableS3ExpressSessionAuth,
29
+ authSchemePreference: typeof config.authSchemePreference === "function" ? await config.authSchemePreference() : config.authSchemePreference,
30
+ sigv4aSigningRegionSet: typeof config.sigv4aSigningRegionSet === "function" ? await config.sigv4aSigningRegionSet() : config.sigv4aSigningRegionSet,
31
+ maxAttempts: typeof config.maxAttempts === "function" ? await config.maxAttempts() : config.maxAttempts,
32
+ retryMode: typeof config.retryMode === "function" ? await config.retryMode() : config.retryMode,
33
+ defaultsMode: typeof config.defaultsMode === "function" ? await config.defaultsMode() : config.defaultsMode,
34
+ userAgentAppId: typeof config.userAgentAppId === "function" ? await config.userAgentAppId() : config.userAgentAppId
35
+ }
36
+ };
37
+ process.send?.({ value: message }, () => process.exit(0));
38
+ } catch {
39
+ process.exitCode = 1;
40
+ process.disconnect?.();
41
+ } finally {
42
+ client.destroy();
43
+ }
44
+ });
45
+ //#endregion
46
+ export {};
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,36 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { extract } from "tar";
5
+ //#region extract-worker.ts
6
+ process.once("message", async ({ archive, staging }) => {
7
+ try {
8
+ let invalidLink = false;
9
+ await pipeline(createReadStream(archive), extract({
10
+ cwd: staging,
11
+ strict: true,
12
+ filter(path, entry) {
13
+ if (!("type" in entry)) return true;
14
+ if (entry.type !== "SymbolicLink" && entry.type !== "Link") return true;
15
+ const link = entry.linkpath ?? "";
16
+ const target = resolve(staging, entry.type === "SymbolicLink" ? dirname(path) : "", link);
17
+ const root = relative(staging, resolve(staging, path)).split(sep)[0];
18
+ const outside = relative(resolve(staging, root ?? ""), target);
19
+ if (isAbsolute(link) || outside === ".." || outside.startsWith(`..${sep}`)) {
20
+ invalidLink = true;
21
+ return false;
22
+ }
23
+ return true;
24
+ }
25
+ }));
26
+ if (invalidLink) throw new Error("The S3 build entry contains a link outside the artifact.");
27
+ process.send?.({ value: true }, () => process.exit(0));
28
+ } catch (error) {
29
+ process.send?.({ error: {
30
+ message: error instanceof Error ? error.message : String(error),
31
+ ...error instanceof Error && "code" in error && typeof error.code === "string" ? { code: error.code } : {}
32
+ } }, () => process.exit(1));
33
+ }
34
+ });
35
+ //#endregion
36
+ export {};
@@ -0,0 +1,22 @@
1
+ import { CACHE_PROVIDER_API_VERSION, CacheProvider, FileCacheCapability } from "@stim-cli/cache";
2
+ //#region index.d.ts
3
+ declare const apiVersion: typeof CACHE_PROVIDER_API_VERSION;
4
+ interface S3FileCacheOptions {
5
+ bucket: string;
6
+ prefix?: string | undefined;
7
+ endpoint?: string | undefined;
8
+ region?: string | undefined;
9
+ profile?: string | undefined;
10
+ forcePathStyle?: boolean | undefined;
11
+ env?: NodeJS.ProcessEnv | undefined;
12
+ projectRoot?: string | undefined;
13
+ }
14
+ /** Stores opaque files; each cache adapter owns its key and serialization. Credentials use the AWS SDK chain. */
15
+ declare function createS3FileCache(options: S3FileCacheOptions): FileCacheCapability & Required<Pick<FileCacheCapability, "stat">>;
16
+ declare function createCacheProvider({ options, projectRoot, env }: {
17
+ projectRoot: string;
18
+ options: Record<string, unknown>;
19
+ env?: NodeJS.ProcessEnv;
20
+ }): CacheProvider;
21
+ //#endregion
22
+ export { S3FileCacheOptions, apiVersion, createCacheProvider, createS3FileCache };
package/dist/index.mjs ADDED
@@ -0,0 +1,393 @@
1
+ import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { Readable, Transform } from "node:stream";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
+ import { deserialize, serialize } from "node:v8";
11
+ import { create } from "tar";
12
+ import { CACHE_PROVIDER_API_VERSION, buildResolveTimeoutMs } from "@stim-cli/cache";
13
+ import { spawn } from "node:child_process";
14
+ import { fileURLToPath } from "node:url";
15
+ //#region worker.ts
16
+ function scopedWorker(worker, input, { env, cwd, signal, timeoutMs, name }) {
17
+ signal.throwIfAborted();
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(process.execPath, [fileURLToPath(worker)], {
20
+ cwd,
21
+ env,
22
+ detached: process.platform !== "win32",
23
+ stdio: [
24
+ "ignore",
25
+ "ignore",
26
+ "ignore",
27
+ "ipc"
28
+ ]
29
+ });
30
+ let reply;
31
+ let failure;
32
+ let closed = false;
33
+ let stopping;
34
+ const stop = () => {
35
+ if (closed || !child.pid) return;
36
+ if (process.platform === "win32") stopping ??= new Promise((done) => {
37
+ const killer = spawn(join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), [
38
+ "/PID",
39
+ String(child.pid),
40
+ "/T",
41
+ "/F"
42
+ ], {
43
+ stdio: "ignore",
44
+ windowsHide: true
45
+ });
46
+ killer.once("error", (error) => {
47
+ failure ??= error;
48
+ });
49
+ killer.once("close", (code) => {
50
+ if (code !== 0) failure ??= /* @__PURE__ */ new Error(`${name} process cleanup failed.`);
51
+ done();
52
+ });
53
+ });
54
+ else try {
55
+ process.kill(-child.pid, "SIGKILL");
56
+ } catch (error) {
57
+ if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) failure ??= error;
58
+ }
59
+ };
60
+ const timer = setTimeout(() => {
61
+ failure = /* @__PURE__ */ new Error(`${name} did not finish within ${timeoutMs / 1e3} seconds.`);
62
+ stop();
63
+ }, timeoutMs);
64
+ signal.addEventListener("abort", stop, { once: true });
65
+ if (signal.aborted) stop();
66
+ child.once("spawn", () => {
67
+ if (signal.aborted) stop();
68
+ else child.send(input, (error) => {
69
+ if (error) {
70
+ failure = error;
71
+ stop();
72
+ }
73
+ });
74
+ });
75
+ child.once("message", (message) => {
76
+ reply = message;
77
+ });
78
+ child.once("error", (error) => {
79
+ failure = error;
80
+ });
81
+ child.once("close", async (code) => {
82
+ closed = true;
83
+ clearTimeout(timer);
84
+ signal.removeEventListener("abort", stop);
85
+ await stopping;
86
+ if (signal.aborted) reject(signal.reason);
87
+ else if (failure) reject(failure);
88
+ else if (reply?.error) reject(Object.assign(new Error(reply.error.message), { code: reply.error.code }));
89
+ else if (code !== 0 || reply?.value === void 0) reject(/* @__PURE__ */ new Error(`Could not complete ${name}.`));
90
+ else resolve(reply.value);
91
+ });
92
+ });
93
+ }
94
+ //#endregion
95
+ //#region credentials.ts
96
+ function scopedClientContext(env, cwd, options, signal) {
97
+ return scopedWorker(new URL(import.meta.url.endsWith(".ts") ? "./credentials-worker.ts" : "./credentials-worker.mjs", import.meta.url), options, {
98
+ env: {
99
+ ...env,
100
+ ...options.profile ? { AWS_PROFILE: options.profile } : {}
101
+ },
102
+ cwd,
103
+ signal,
104
+ timeoutMs: 1e4,
105
+ name: "S3 credential resolution"
106
+ }).then((context) => {
107
+ const { expiration, ...credentials } = context.credentials;
108
+ return {
109
+ credentials: {
110
+ ...credentials,
111
+ ...expiration ? { expiration: new Date(expiration) } : {}
112
+ },
113
+ requestConfig: context.requestConfig
114
+ };
115
+ });
116
+ }
117
+ //#endregion
118
+ //#region index.ts
119
+ const apiVersion = CACHE_PROVIDER_API_VERSION;
120
+ async function fileDigests(path, signal) {
121
+ const sha256 = createHash("sha256");
122
+ const md5 = createHash("md5");
123
+ for await (const chunk of createReadStream(path, { signal })) {
124
+ sha256.update(chunk);
125
+ md5.update(chunk);
126
+ }
127
+ return {
128
+ sha256: sha256.digest("hex"),
129
+ md5: md5.digest("base64")
130
+ };
131
+ }
132
+ function status(error) {
133
+ if (!error || typeof error !== "object" || !("$metadata" in error)) return void 0;
134
+ const metadata = error.$metadata;
135
+ if (!metadata || typeof metadata !== "object" || !("httpStatusCode" in metadata)) return void 0;
136
+ return typeof metadata.httpStatusCode === "number" ? metadata.httpStatusCode : void 0;
137
+ }
138
+ /** Stores opaque files; each cache adapter owns its key and serialization. Credentials use the AWS SDK chain. */
139
+ function createS3FileCache(options) {
140
+ if (!options.bucket) throw new Error("The S3 cache needs a bucket.");
141
+ const config = {
142
+ ...options.endpoint !== void 0 ? { endpoint: options.endpoint } : {},
143
+ region: options.region ?? "us-east-1",
144
+ ...options.profile !== void 0 ? { profile: options.profile } : {},
145
+ ...options.forcePathStyle !== void 0 ? { forcePathStyle: options.forcePathStyle } : {},
146
+ requestChecksumCalculation: "WHEN_REQUIRED",
147
+ responseChecksumValidation: "WHEN_REQUIRED",
148
+ logger: {
149
+ debug() {},
150
+ info() {},
151
+ warn() {},
152
+ error() {}
153
+ }
154
+ };
155
+ const environment = options.env === void 0 ? void 0 : { ...options.env };
156
+ const defaultClient = environment ? void 0 : new S3Client(config);
157
+ let context;
158
+ async function withClient(signal, run) {
159
+ signal.throwIfAborted();
160
+ if (defaultClient) return run(defaultClient);
161
+ if (!context || context.credentials.expiration && context.credentials.expiration.getTime() - Date.now() < 3e5) context = await scopedClientContext(environment ?? {}, options.projectRoot ?? process.cwd(), {
162
+ profile: options.profile,
163
+ region: options.region ?? "us-east-1",
164
+ endpoint: options.endpoint
165
+ }, signal);
166
+ signal.throwIfAborted();
167
+ const captured = context;
168
+ const scoped = new S3Client({
169
+ ...config,
170
+ ...captured.requestConfig,
171
+ credentials: captured.credentials,
172
+ ignoreConfiguredEndpointUrls: true,
173
+ useArnRegion: async () => captured.requestConfig.useArnRegion,
174
+ disableS3ExpressSessionAuth: async () => captured.requestConfig.disableS3ExpressSessionAuth,
175
+ sigv4aSigningRegionSet: async () => captured.requestConfig.sigv4aSigningRegionSet,
176
+ userAgentAppId: async () => captured.requestConfig.userAgentAppId
177
+ });
178
+ try {
179
+ return await run(scoped);
180
+ } finally {
181
+ scoped.destroy();
182
+ }
183
+ }
184
+ const prefix = (options.prefix ?? "stim/v1").replace(/^\/+|\/+$/g, "");
185
+ const objectKey = (key) => `${prefix}/${key}`;
186
+ return {
187
+ async stat({ key, signal }) {
188
+ return withClient(signal, async (client) => {
189
+ signal.throwIfAborted();
190
+ try {
191
+ const object = await client.send(new HeadObjectCommand({
192
+ Bucket: options.bucket,
193
+ Key: objectKey(key)
194
+ }), { abortSignal: signal });
195
+ return typeof object.ContentLength === "number" ? { size: object.ContentLength } : null;
196
+ } catch (error) {
197
+ if (status(error) === 404) return null;
198
+ throw error;
199
+ }
200
+ });
201
+ },
202
+ async resolve({ key, destinationDir, signal }) {
203
+ return withClient(signal, async (client) => {
204
+ signal.throwIfAborted();
205
+ let object;
206
+ try {
207
+ object = await client.send(new GetObjectCommand({
208
+ Bucket: options.bucket,
209
+ Key: objectKey(key)
210
+ }), { abortSignal: signal });
211
+ } catch (error) {
212
+ if (status(error) === 404) return null;
213
+ throw error;
214
+ }
215
+ const expected = object.Metadata?.sha256;
216
+ if (!(object.Body instanceof Readable) || !expected || !/^[a-f0-9]{64}$/.test(expected)) {
217
+ if (object.Body instanceof Readable) object.Body.destroy();
218
+ throw new Error("The S3 cache entry has no SHA-256 metadata.");
219
+ }
220
+ const temporary = join(destinationDir, `${randomUUID()}.partial`);
221
+ const complete = join(destinationDir, `${randomUUID()}.cache`);
222
+ const digest = createHash("sha256");
223
+ try {
224
+ await mkdir(destinationDir, { recursive: true });
225
+ await pipeline(object.Body, new Transform({ transform(chunk, _encoding, callback) {
226
+ digest.update(chunk);
227
+ callback(null, chunk);
228
+ } }), createWriteStream(temporary, { flags: "wx" }), { signal });
229
+ if (digest.digest("hex") !== expected) throw new Error("The S3 cache entry failed SHA-256 verification.");
230
+ signal.throwIfAborted();
231
+ await rename(temporary, complete);
232
+ return complete;
233
+ } finally {
234
+ object.Body.destroy();
235
+ await rm(temporary, { force: true });
236
+ }
237
+ });
238
+ },
239
+ async store({ key, sourcePath, overwrite, signal }) {
240
+ return withClient(signal, async (client) => {
241
+ signal.throwIfAborted();
242
+ const digests = await fileDigests(sourcePath, signal);
243
+ const size = (await stat(sourcePath)).size;
244
+ for (let attempt = 0;; attempt++) {
245
+ signal.throwIfAborted();
246
+ const body = createReadStream(sourcePath, { signal });
247
+ try {
248
+ await client.send(new PutObjectCommand({
249
+ Bucket: options.bucket,
250
+ Key: objectKey(key),
251
+ Body: body,
252
+ ContentLength: size,
253
+ ContentMD5: digests.md5,
254
+ Metadata: { sha256: digests.sha256 },
255
+ IfNoneMatch: overwrite ? void 0 : "*"
256
+ }), { abortSignal: signal });
257
+ return;
258
+ } catch (error) {
259
+ if (!overwrite && status(error) === 412) return;
260
+ if (attempt === 2 || signal.aborted || !(error instanceof Error) || !("code" in error) || error.code !== "ECONNRESET") throw error;
261
+ } finally {
262
+ body.destroy();
263
+ }
264
+ await setTimeout$1(100 * 2 ** attempt, void 0, { signal });
265
+ }
266
+ });
267
+ }
268
+ };
269
+ }
270
+ function cleanupFailure(path, cause) {
271
+ return Object.assign(new Error(`S3 cache cleanup failed. Retained ${path}.`, { cause }), {
272
+ code: "STIM_CLAIM_UNAVAILABLE",
273
+ reportCleanup: true,
274
+ remedy: "Check the retained directory permissions and remove it before retrying."
275
+ });
276
+ }
277
+ async function scratch(run, parent) {
278
+ if (parent) await mkdir(parent, { recursive: true });
279
+ const dir = await mkdtemp(join(parent ?? tmpdir(), "stim-s3-cache-"));
280
+ return Promise.resolve().then(() => run(dir)).finally(async () => {
281
+ try {
282
+ await rm(dir, {
283
+ recursive: true,
284
+ force: true
285
+ });
286
+ } catch (cause) {
287
+ throw cleanupFailure(parent ?? dir, cause);
288
+ }
289
+ });
290
+ }
291
+ function createCacheProvider({ options, projectRoot, env }) {
292
+ const environment = env === void 0 ? void 0 : { ...env };
293
+ const files = createS3FileCache({
294
+ env: environment,
295
+ projectRoot,
296
+ bucket: typeof options.bucket === "string" ? options.bucket : "",
297
+ prefix: typeof options.prefix === "string" ? options.prefix : void 0,
298
+ endpoint: typeof options.endpoint === "string" ? options.endpoint : void 0,
299
+ region: typeof options.region === "string" ? options.region : void 0,
300
+ profile: typeof options.profile === "string" ? options.profile : void 0,
301
+ forcePathStyle: typeof options.forcePathStyle === "boolean" ? options.forcePathStyle : void 0
302
+ });
303
+ return {
304
+ files,
305
+ builds: {
306
+ async resolve({ platform, key, destinationDir, signal }) {
307
+ return scratch(async (dir) => {
308
+ const archive = await files.resolve({
309
+ key: `builds/${platform}/${key}`,
310
+ destinationDir: dir,
311
+ signal
312
+ });
313
+ if (!archive) return null;
314
+ const staging = await mkdtemp(join(destinationDir, "s3-"));
315
+ try {
316
+ await scopedWorker(new URL(import.meta.url.endsWith(".ts") ? "./extract-worker.ts" : "./extract-worker.mjs", import.meta.url), {
317
+ archive,
318
+ staging
319
+ }, {
320
+ env: { ...environment ?? process.env },
321
+ cwd: projectRoot,
322
+ signal,
323
+ timeoutMs: buildResolveTimeoutMs(environment),
324
+ name: "S3 archive extraction"
325
+ });
326
+ signal.throwIfAborted();
327
+ const entries = await readdir(staging);
328
+ const [entry] = entries;
329
+ if (entries.length !== 1 || entry === void 0) throw new Error("The S3 build entry must contain one artifact.");
330
+ const path = join(staging, entry);
331
+ const info = await lstat(path);
332
+ if (platform === "ios" ? !info.isDirectory() || !path.endsWith(".app") : !info.isFile() || !path.endsWith(".apk")) throw new Error("The S3 build entry has the wrong artifact type.");
333
+ return path;
334
+ } catch (error) {
335
+ try {
336
+ await rm(staging, {
337
+ recursive: true,
338
+ force: true
339
+ });
340
+ } catch (cause) {
341
+ throw cleanupFailure(destinationDir, cause);
342
+ }
343
+ throw error;
344
+ }
345
+ }, destinationDir);
346
+ },
347
+ async store({ platform, key, sourcePath, overwrite, signal }) {
348
+ await scratch(async (dir) => {
349
+ signal.throwIfAborted();
350
+ const archive = join(dir, "artifact.tar");
351
+ await pipeline(create({
352
+ cwd: dirname(sourcePath),
353
+ portable: true
354
+ }, [basename(sourcePath)]), createWriteStream(archive, { flags: "wx" }), { signal });
355
+ signal.throwIfAborted();
356
+ await files.store({
357
+ key: `builds/${platform}/${key}`,
358
+ sourcePath: archive,
359
+ overwrite,
360
+ signal
361
+ });
362
+ });
363
+ }
364
+ },
365
+ metro: {
366
+ async get({ cacheName, key, signal }) {
367
+ return scratch(async (dir) => {
368
+ const path = await files.resolve({
369
+ key: `metro/${encodeURIComponent(cacheName)}/${key.toString("hex")}`,
370
+ destinationDir: dir,
371
+ signal
372
+ });
373
+ if (!path) return null;
374
+ return deserialize(await readFile(path, { signal }));
375
+ });
376
+ },
377
+ async set({ cacheName, key, value, signal }) {
378
+ await scratch(async (dir) => {
379
+ const path = join(dir, "transform");
380
+ await writeFile(path, serialize(value), { signal });
381
+ await files.store({
382
+ key: `metro/${encodeURIComponent(cacheName)}/${key.toString("hex")}`,
383
+ sourcePath: path,
384
+ overwrite: true,
385
+ signal
386
+ });
387
+ });
388
+ }
389
+ }
390
+ };
391
+ }
392
+ //#endregion
393
+ export { apiVersion, createCacheProvider, createS3FileCache };
package/package.json CHANGED
@@ -1,11 +1,37 @@
1
1
  {
2
2
  "name": "@stim-cli/cache-s3",
3
- "version": "0.0.0",
4
- "description": "Placeholder. The first real version is published by the Stim release workflow.",
3
+ "version": "1.22.0",
4
+ "description": "Optional S3-compatible file, native artifact and Metro cache provider for Stim.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/appandflow/stim.git",
9
9
  "directory": "packages/cache-s3"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "main": "dist/index.mjs",
18
+ "types": "dist/index.d.mts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.mts",
22
+ "module-sync": "./dist/index.mjs",
23
+ "default": "./dist/index.mjs"
24
+ }
25
+ },
26
+ "dependencies": {
27
+ "@aws-sdk/client-s3": "^3.1149.0",
28
+ "@stim-cli/cache": "^1.22.0",
29
+ "tar": "^7.5.22"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.12.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown"
10
36
  }
11
- }
37
+ }