@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 +21 -0
- package/README.md +76 -1
- package/dist/credentials-worker.d.mts +1 -0
- package/dist/credentials-worker.mjs +46 -0
- package/dist/extract-worker.d.mts +1 -0
- package/dist/extract-worker.mjs +36 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.mjs +393 -0
- package/package.json +29 -3
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
|
-
|
|
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 {};
|
package/dist/index.d.mts
ADDED
|
@@ -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": "
|
|
4
|
-
"description": "
|
|
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
|
+
}
|