@stim-cli/cache-s3 0.0.0 → 1.21.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,65 @@
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
+
37
+ Create a private R2 bucket and an R2 API token with Object Read & Write access
38
+ scoped to that bucket. Copy the S3 endpoint shown by Cloudflare; jurisdiction
39
+ specific buckets use their own endpoint. See the
40
+ [R2 S3 setup guide](https://developers.cloudflare.com/r2/get-started/s3/).
41
+
42
+ The provider shares completed native `.app`/`.apk` artifacts and Metro values
43
+ through one file transport. Stim checks its local tier first and promotes build
44
+ hits locally. Build archives preserve file modes and contained framework links;
45
+ downloads verify SHA-256 before becoming reusable. Conditional writes preserve
46
+ existing build entries unless overwrite was requested. Use matching native
47
+ toolchains for the initial test; the provider does not add toolchain information
48
+ to Stim's existing build key.
49
+
50
+ The file transport is also exported as `createS3FileCache()` from
51
+ `@stim-cli/cache-s3`, with the `FileCacheCapability` interface exported from
52
+ `@stim-cli/cache`. A cache adapter supplies a compatible key and a completed
53
+ file, then owns the downloaded file inside its destination directory. This
54
+ also lets native Gradle artifact caching share the completed APK and verification
55
+ receipt through this transport when `android.artifactInputs` is declared. Private
56
+ input files and environment values stay local. Other Stim home directories are
57
+ not automatically synchronized.
58
+
59
+ Objects use `<prefix>/builds/<platform>/<key>` and
60
+ `<prefix>/metro/<cacheName>/<hex-key>`. Native Gradle APKs use
61
+ `<prefix>/native-android/v1/<key>`. A prefix defaults to `stim/v1`. Keep
62
+ incompatible snapshot formats in separate key namespaces. Retention belongs to
63
+ the bucket lifecycle configuration; local GC never removes shared objects.
64
+ Uploads use a single S3 PUT, so entries larger than S3's 5 GiB limit are not
65
+ supported by this initial provider.
@@ -0,0 +1,19 @@
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
+ }
12
+ /** Stores opaque files; each cache adapter owns its key and serialization. Credentials use the AWS SDK chain. */
13
+ declare function createS3FileCache(options: S3FileCacheOptions): FileCacheCapability & Required<Pick<FileCacheCapability, "stat">>;
14
+ declare function createCacheProvider({ options }: {
15
+ projectRoot: string;
16
+ options: Record<string, unknown>;
17
+ }): CacheProvider;
18
+ //#endregion
19
+ export { S3FileCacheOptions, apiVersion, createCacheProvider, createS3FileCache };
package/dist/index.mjs ADDED
@@ -0,0 +1,238 @@
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, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { Readable, Transform } from "node:stream";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { deserialize, serialize } from "node:v8";
10
+ import { create, extract } from "tar";
11
+ import { CACHE_PROVIDER_API_VERSION } from "@stim-cli/cache";
12
+ //#region index.ts
13
+ const apiVersion = CACHE_PROVIDER_API_VERSION;
14
+ async function fileDigests(path, signal) {
15
+ const sha256 = createHash("sha256");
16
+ const md5 = createHash("md5");
17
+ for await (const chunk of createReadStream(path, { signal })) {
18
+ sha256.update(chunk);
19
+ md5.update(chunk);
20
+ }
21
+ return {
22
+ sha256: sha256.digest("hex"),
23
+ md5: md5.digest("base64")
24
+ };
25
+ }
26
+ function status(error) {
27
+ if (!error || typeof error !== "object" || !("$metadata" in error)) return void 0;
28
+ const metadata = error.$metadata;
29
+ if (!metadata || typeof metadata !== "object" || !("httpStatusCode" in metadata)) return void 0;
30
+ return typeof metadata.httpStatusCode === "number" ? metadata.httpStatusCode : void 0;
31
+ }
32
+ /** Stores opaque files; each cache adapter owns its key and serialization. Credentials use the AWS SDK chain. */
33
+ function createS3FileCache(options) {
34
+ if (!options.bucket) throw new Error("The S3 cache needs a bucket.");
35
+ const config = {
36
+ ...options.endpoint !== void 0 ? { endpoint: options.endpoint } : {},
37
+ region: options.region ?? "us-east-1",
38
+ ...options.profile !== void 0 ? { profile: options.profile } : {},
39
+ ...options.forcePathStyle !== void 0 ? { forcePathStyle: options.forcePathStyle } : {},
40
+ requestChecksumCalculation: "WHEN_REQUIRED",
41
+ responseChecksumValidation: "WHEN_REQUIRED",
42
+ logger: {
43
+ debug() {},
44
+ info() {},
45
+ warn() {},
46
+ error() {}
47
+ }
48
+ };
49
+ const client = new S3Client(config);
50
+ const prefix = (options.prefix ?? "stim/v1").replace(/^\/+|\/+$/g, "");
51
+ const objectKey = (key) => `${prefix}/${key}`;
52
+ return {
53
+ async stat({ key, signal }) {
54
+ signal.throwIfAborted();
55
+ try {
56
+ const object = await client.send(new HeadObjectCommand({
57
+ Bucket: options.bucket,
58
+ Key: objectKey(key)
59
+ }), { abortSignal: signal });
60
+ return typeof object.ContentLength === "number" ? { size: object.ContentLength } : null;
61
+ } catch (error) {
62
+ if (status(error) === 404) return null;
63
+ throw error;
64
+ }
65
+ },
66
+ async resolve({ key, destinationDir, signal }) {
67
+ signal.throwIfAborted();
68
+ let object;
69
+ try {
70
+ object = await client.send(new GetObjectCommand({
71
+ Bucket: options.bucket,
72
+ Key: objectKey(key)
73
+ }), { abortSignal: signal });
74
+ } catch (error) {
75
+ if (status(error) === 404) return null;
76
+ throw error;
77
+ }
78
+ const expected = object.Metadata?.sha256;
79
+ if (!(object.Body instanceof Readable) || !expected || !/^[a-f0-9]{64}$/.test(expected)) {
80
+ if (object.Body instanceof Readable) object.Body.destroy();
81
+ throw new Error("The S3 cache entry has no SHA-256 metadata.");
82
+ }
83
+ const temporary = join(destinationDir, `${randomUUID()}.partial`);
84
+ const complete = join(destinationDir, `${randomUUID()}.cache`);
85
+ const digest = createHash("sha256");
86
+ try {
87
+ await mkdir(destinationDir, { recursive: true });
88
+ await pipeline(object.Body, new Transform({ transform(chunk, _encoding, callback) {
89
+ digest.update(chunk);
90
+ callback(null, chunk);
91
+ } }), createWriteStream(temporary, { flags: "wx" }), { signal });
92
+ if (digest.digest("hex") !== expected) throw new Error("The S3 cache entry failed SHA-256 verification.");
93
+ signal.throwIfAborted();
94
+ await rename(temporary, complete);
95
+ return complete;
96
+ } finally {
97
+ object.Body.destroy();
98
+ await rm(temporary, { force: true });
99
+ }
100
+ },
101
+ async store({ key, sourcePath, overwrite, signal }) {
102
+ signal.throwIfAborted();
103
+ const digests = await fileDigests(sourcePath, signal);
104
+ const body = createReadStream(sourcePath, { signal });
105
+ try {
106
+ await client.send(new PutObjectCommand({
107
+ Bucket: options.bucket,
108
+ Key: objectKey(key),
109
+ Body: body,
110
+ ContentLength: (await stat(sourcePath)).size,
111
+ ContentMD5: digests.md5,
112
+ Metadata: { sha256: digests.sha256 },
113
+ IfNoneMatch: overwrite ? void 0 : "*"
114
+ }), { abortSignal: signal });
115
+ } catch (error) {
116
+ if (overwrite || status(error) !== 412) throw error;
117
+ } finally {
118
+ body.destroy();
119
+ }
120
+ }
121
+ };
122
+ }
123
+ async function scratch(run) {
124
+ const dir = await mkdtemp(join(tmpdir(), "stim-s3-cache-"));
125
+ try {
126
+ return await run(dir);
127
+ } finally {
128
+ await rm(dir, {
129
+ recursive: true,
130
+ force: true
131
+ });
132
+ }
133
+ }
134
+ function createCacheProvider({ options }) {
135
+ const files = createS3FileCache({
136
+ bucket: typeof options.bucket === "string" ? options.bucket : "",
137
+ prefix: typeof options.prefix === "string" ? options.prefix : void 0,
138
+ endpoint: typeof options.endpoint === "string" ? options.endpoint : void 0,
139
+ region: typeof options.region === "string" ? options.region : void 0,
140
+ profile: typeof options.profile === "string" ? options.profile : void 0,
141
+ forcePathStyle: typeof options.forcePathStyle === "boolean" ? options.forcePathStyle : void 0
142
+ });
143
+ return {
144
+ files,
145
+ builds: {
146
+ async resolve({ platform, key, destinationDir, signal }) {
147
+ return scratch(async (dir) => {
148
+ const archive = await files.resolve({
149
+ key: `builds/${platform}/${key}`,
150
+ destinationDir: dir,
151
+ signal
152
+ });
153
+ if (!archive) return null;
154
+ const staging = await mkdtemp(join(destinationDir, "s3-"));
155
+ try {
156
+ let invalidLink = false;
157
+ await pipeline(createReadStream(archive), extract({
158
+ cwd: staging,
159
+ strict: true,
160
+ filter(path, entry) {
161
+ if (!("type" in entry)) return true;
162
+ if (entry.type !== "SymbolicLink" && entry.type !== "Link") return true;
163
+ const link = entry.linkpath ?? "";
164
+ const target = resolve(staging, entry.type === "SymbolicLink" ? dirname(path) : "", link);
165
+ const root = relative(staging, resolve(staging, path)).split(sep)[0];
166
+ const outside = relative(resolve(staging, root ?? ""), target);
167
+ if (isAbsolute(link) || outside === ".." || outside.startsWith(`..${sep}`)) {
168
+ invalidLink = true;
169
+ return false;
170
+ }
171
+ return true;
172
+ }
173
+ }), { signal });
174
+ if (invalidLink) throw new Error("The S3 build entry contains a link outside the artifact.");
175
+ signal.throwIfAborted();
176
+ const entries = await readdir(staging);
177
+ const [entry] = entries;
178
+ if (entries.length !== 1 || entry === void 0) throw new Error("The S3 build entry must contain one artifact.");
179
+ const path = join(staging, entry);
180
+ const info = await lstat(path);
181
+ 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.");
182
+ return path;
183
+ } catch (error) {
184
+ await rm(staging, {
185
+ recursive: true,
186
+ force: true
187
+ });
188
+ throw error;
189
+ }
190
+ });
191
+ },
192
+ async store({ platform, key, sourcePath, overwrite, signal }) {
193
+ await scratch(async (dir) => {
194
+ signal.throwIfAborted();
195
+ const archive = join(dir, "artifact.tar");
196
+ await pipeline(create({
197
+ cwd: dirname(sourcePath),
198
+ portable: true
199
+ }, [basename(sourcePath)]), createWriteStream(archive, { flags: "wx" }), { signal });
200
+ signal.throwIfAborted();
201
+ await files.store({
202
+ key: `builds/${platform}/${key}`,
203
+ sourcePath: archive,
204
+ overwrite,
205
+ signal
206
+ });
207
+ });
208
+ }
209
+ },
210
+ metro: {
211
+ async get({ cacheName, key, signal }) {
212
+ return scratch(async (dir) => {
213
+ const path = await files.resolve({
214
+ key: `metro/${encodeURIComponent(cacheName)}/${key.toString("hex")}`,
215
+ destinationDir: dir,
216
+ signal
217
+ });
218
+ if (!path) return null;
219
+ return deserialize(await readFile(path, { signal }));
220
+ });
221
+ },
222
+ async set({ cacheName, key, value, signal }) {
223
+ await scratch(async (dir) => {
224
+ const path = join(dir, "transform");
225
+ await writeFile(path, serialize(value), { signal });
226
+ await files.store({
227
+ key: `metro/${encodeURIComponent(cacheName)}/${key.toString("hex")}`,
228
+ sourcePath: path,
229
+ overwrite: true,
230
+ signal
231
+ });
232
+ });
233
+ }
234
+ }
235
+ };
236
+ }
237
+ //#endregion
238
+ 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.21.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.21.0",
29
+ "tar": "^7.5.22"
30
+ },
31
+ "engines": {
32
+ "node": ">=22.12.0"
33
+ },
34
+ "scripts": {
35
+ "build": "tsdown"
10
36
  }
11
- }
37
+ }