Skip to content

Commit 184cc74

Browse files
authored
feat: official support for custom remote cache providers (#331)
* feat: add missing methods to remote cache provider * add test * unify interface * mark delete and upload as optional * changeset * simplify return to array only * remove id * simplify upload return * refactor getLocalBinaryPath * get rid of local artifact path as it's handled by RNEF; pass targetURL * make download return Reponse and move logic to RNEF * rework provider plugin to be configurable * add throws to TSDoc * implement RemoteBuildCache in example * cleanup * use Error
1 parent 0710ad9 commit 184cc74

File tree

11 files changed

+431
-222
lines changed

11 files changed

+431
-222
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@rnef/config': patch
3+
'@rnef/tools': patch
4+
'rnef-docs': patch
5+
---
6+
7+
feat: official support for custom remote cache providers

packages/config/src/lib/schema.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ const ConfigTypeSchema = Joi.object({
4040
plugins: Joi.array().items(Joi.function()).optional(),
4141
platforms: Joi.object().pattern(Joi.string(), Joi.function()).optional(),
4242
commands: Joi.array().items(CommandTypeSchema).optional(),
43-
remoteCacheProvider: Joi.string()
44-
.valid('github-actions', null, Joi.function())
45-
.optional(),
43+
remoteCacheProvider: Joi.alternatives().try(
44+
Joi.string().valid('github-actions'),
45+
Joi.function(),
46+
Joi.any().valid(null)
47+
).optional(),
4648
fingerprint: Joi.object({
4749
extraSources: Joi.array().items(Joi.string()).default([]),
4850
ignorePaths: Joi.array().items(Joi.string()).default([]),
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect, test } from 'vitest';
2+
import type { RemoteBuildCache } from '../build-cache/common.js';
3+
import { createRemoteBuildCache } from '../build-cache/remoteBuildCache.js';
4+
5+
const uploadMock = vi.fn();
6+
7+
class DummyRemoteCacheProvider implements RemoteBuildCache {
8+
name = 'dummy';
9+
constructor(options?: { name: string }) {
10+
if (options) {
11+
this.name = options.name;
12+
}
13+
}
14+
async list({ artifactName }: { artifactName: string }) {
15+
return [{ name: artifactName, url: '/path/to/dummy' }];
16+
}
17+
async download({ artifactName }: { artifactName: string }) {
18+
const resBody = new TextEncoder().encode(artifactName);
19+
return new Response(resBody);
20+
}
21+
async delete({ artifactName }: { artifactName: string }) {
22+
if (artifactName === 'dummy') {
23+
return [{ name: artifactName, url: '/path/to/dummy' }];
24+
}
25+
return [];
26+
}
27+
async upload({ artifactName }: { artifactName: string }) {
28+
uploadMock(artifactName);
29+
return { name: artifactName, url: '/path/to/dummy' };
30+
}
31+
}
32+
33+
test('dummy remote cache provider lists artifacts', async () => {
34+
const pluginDummyRemoteCacheProvider = (options?: { name: string }) => () =>
35+
new DummyRemoteCacheProvider(options);
36+
const remoteBuildCache = await createRemoteBuildCache(
37+
pluginDummyRemoteCacheProvider()
38+
);
39+
const artifacts = await remoteBuildCache?.list({
40+
artifactName: 'rnef-android-debug-7af554b93cd696ca95308fdebe3a4484001bb7b4',
41+
});
42+
expect(artifacts).toEqual([
43+
{
44+
name: 'rnef-android-debug-7af554b93cd696ca95308fdebe3a4484001bb7b4',
45+
url: '/path/to/dummy',
46+
},
47+
]);
48+
});
49+
50+
test('dummy remote cache provider downloads artifacts', async () => {
51+
const pluginDummyRemoteCacheProvider = (options?: { name: string }) => () =>
52+
new DummyRemoteCacheProvider(options);
53+
const remoteBuildCache = await createRemoteBuildCache(
54+
pluginDummyRemoteCacheProvider()
55+
);
56+
const artifact = await remoteBuildCache?.download({
57+
artifactName: 'rnef-android-debug-7af554b93cd696ca95308fdebe3a4484001bb7b4',
58+
});
59+
const response = await artifact?.text();
60+
expect(response).toEqual(
61+
'rnef-android-debug-7af554b93cd696ca95308fdebe3a4484001bb7b4'
62+
);
63+
});
64+
65+
test('dummy remote cache provider deletes artifacts', async () => {
66+
const pluginDummyRemoteCacheProvider = (options?: { name: string }) => () =>
67+
new DummyRemoteCacheProvider(options);
68+
const remoteBuildCache = await createRemoteBuildCache(
69+
pluginDummyRemoteCacheProvider()
70+
);
71+
const result = await remoteBuildCache?.delete({ artifactName: 'dummy' });
72+
expect(result).toEqual([
73+
{
74+
name: 'dummy',
75+
url: '/path/to/dummy',
76+
},
77+
]);
78+
const result2 = await remoteBuildCache?.delete({ artifactName: 'dummy2' });
79+
expect(result2).toEqual([]);
80+
});
81+
82+
test('dummy remote cache provider uploads artifacts', async () => {
83+
const pluginDummyRemoteCacheProvider = (options?: { name: string }) => () =>
84+
new DummyRemoteCacheProvider(options);
85+
const remoteBuildCache = await createRemoteBuildCache(
86+
pluginDummyRemoteCacheProvider()
87+
);
88+
await remoteBuildCache?.upload({ artifactName: 'dummy' });
89+
expect(uploadMock).toHaveBeenCalledWith('dummy');
90+
});

packages/tools/src/lib/build-cache/common.ts

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,65 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { nativeFingerprint } from '../fingerprint/index.js';
44
import { getCacheRootPath } from '../project.js';
5-
import type { spinner } from '../prompts.js';
65

76
export const BUILD_CACHE_DIR = 'remote-build';
87

98
export type SupportedRemoteCacheProviders = 'github-actions';
109

1110
export type RemoteArtifact = {
1211
name: string;
13-
downloadUrl: string;
12+
url: string;
1413
};
1514

1615
export type LocalArtifact = {
1716
name: string;
18-
path: string;
1917
};
2018

19+
/**
20+
* Interface for implementing remote build cache providers.
21+
* Remote cache providers allow storing and retrieving native build artifacts (e.g. APK, IPA)
22+
* from remote storage like S3, GitHub Artifacts etc.
23+
*/
2124
export interface RemoteBuildCache {
25+
/** Unique identifier for this cache provider, will be displayed in logs */
2226
name: string;
23-
query({
27+
28+
/**
29+
* List available artifacts matching the given name pattern
30+
* @param artifactName - Passed after fingerprinting the build, e.g. `rnef-android-debug-1234567890` for android in debug variant
31+
* @param limit - Optional maximum number of artifacts to return
32+
* @returns Array of matching remote artifacts, or empty array if none found
33+
*/
34+
list({
2435
artifactName,
36+
limit,
2537
}: {
26-
artifactName: string;
27-
}): Promise<RemoteArtifact | null>;
28-
download({
29-
artifact,
30-
loader,
31-
}: {
32-
artifact: RemoteArtifact;
33-
loader: ReturnType<typeof spinner>;
34-
}): Promise<LocalArtifact>;
38+
artifactName: string | undefined;
39+
limit?: number;
40+
}): Promise<RemoteArtifact[]>;
41+
42+
/**
43+
* Download a remote artifact to local storage
44+
* @param artifactName - Name of the artifact to download, e.g. `rnef-android-debug-1234567890` for android in debug variant
45+
* @returns Response object from fetch, which will be used to download the artifact
46+
*/
47+
download({ artifactName }: { artifactName: string }): Promise<Response>;
48+
49+
/**
50+
* Delete a remote artifact
51+
* @param artifact - Remote artifact to delete, as returned by `list` method
52+
* @returns Array of deleted artifacts
53+
* @throws {Error} Throws if artifact is not found or deletion fails
54+
*/
55+
delete({ artifactName }: { artifactName: string }): Promise<RemoteArtifact[]>;
56+
57+
/**
58+
* Upload a local artifact stored in build cache to remote storage
59+
* @param artifactName - Name of the artifact to upload, e.g. `rnef-android-debug-1234567890` for android in debug variant
60+
* @returns Remote artifact info if upload successful
61+
* @throws {Error} Throws if upload fails
62+
*/
63+
upload({ artifactName }: { artifactName: string }): Promise<RemoteArtifact>;
3564
}
3665

3766
/**
@@ -63,16 +92,8 @@ export function getLocalArtifactPath(artifactName: string) {
6392
}
6493

6594
export function getLocalBinaryPath(artifactPath: string) {
66-
let binaryPath: string | null = null;
6795
const files = fs.readdirSync(artifactPath);
68-
69-
// assume there is only one binary in the artifact
70-
for (const file of files) {
71-
if (file) {
72-
binaryPath = path.join(artifactPath, file);
73-
}
74-
break;
75-
}
76-
77-
return binaryPath;
96+
// Get the first non-hidden, non-directory file as the binary
97+
const binaryName = files.find((file) => file && !file.startsWith('.'));
98+
return binaryName ? path.join(artifactPath, binaryName) : null;
7899
}

packages/tools/src/lib/build-cache/fetchCachedBuild.ts

Lines changed: 93 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import fs from 'node:fs';
12
import path from 'node:path';
3+
import AdmZip from 'adm-zip';
4+
import * as tar from 'tar';
25
import { color } from '../color.js';
6+
import { RnefError } from '../error.js';
37
import logger from '../logger.js';
48
import { getProjectRoot } from '../project.js';
59
import { spinner } from '../prompts.js';
610
import {
11+
getLocalArtifactPath,
712
getLocalBinaryPath,
813
type RemoteBuildCache,
914
type SupportedRemoteCacheProviders,
@@ -20,7 +25,7 @@ type FetchCachedBuildOptions = {
2025
| SupportedRemoteCacheProviders
2126
| undefined
2227
| null
23-
| { new (): RemoteBuildCache };
28+
| { (): RemoteBuildCache };
2429
};
2530

2631
export async function fetchCachedBuild({
@@ -61,32 +66,103 @@ Proceeding with local build.`);
6166

6267
loader.stop(`No local build cached. Checking ${remoteBuildCache.name}.`);
6368

64-
const remoteBuild = await remoteBuildCache.query({ artifactName });
65-
if (!remoteBuild) {
66-
loader.start('');
67-
loader.stop(`No cached build found for "${artifactName}".`);
68-
return null;
69-
}
70-
69+
const localArtifactPath = getLocalArtifactPath(artifactName);
70+
const response = await remoteBuildCache.download({ artifactName });
7171
loader.start(`Downloading cached build from ${remoteBuildCache.name}`);
72-
const fetchedBuild = await remoteBuildCache.download({
73-
artifact: remoteBuild,
74-
loader,
75-
});
76-
77-
const binaryPath = getLocalBinaryPath(fetchedBuild.path);
72+
await handleDownloadResponse(
73+
response,
74+
localArtifactPath,
75+
remoteBuildCache.name,
76+
loader
77+
);
78+
await extractArtifactTarballIfNeeded(localArtifactPath);
79+
const binaryPath = getLocalBinaryPath(localArtifactPath);
7880
if (!binaryPath) {
7981
loader.stop(`No binary found for "${artifactName}".`);
8082
return null;
8183
}
82-
8384
loader.stop(
8485
`Downloaded cached build: ${color.cyan(path.relative(root, binaryPath))}.`
8586
);
8687

8788
return {
88-
name: fetchedBuild.name,
89-
artifactPath: fetchedBuild.path,
89+
name: artifactName,
90+
artifactPath: localArtifactPath,
9091
binaryPath,
9192
};
9293
}
94+
95+
async function handleDownloadResponse(
96+
response: Response,
97+
localArtifactPath: string,
98+
name: string,
99+
loader: ReturnType<typeof spinner>
100+
) {
101+
try {
102+
fs.mkdirSync(localArtifactPath, { recursive: true });
103+
if (!response.ok || !response.body) {
104+
throw new Error(`Failed to download artifact: ${response.statusText}`);
105+
}
106+
let responseData = response;
107+
const contentLength = response.headers.get('content-length');
108+
109+
if (contentLength) {
110+
const totalBytes = parseInt(contentLength, 10);
111+
const totalMB = (totalBytes / 1024 / 1024).toFixed(2);
112+
let downloadedBytes = 0;
113+
114+
const reader = response.body.getReader();
115+
const stream = new ReadableStream({
116+
async start(controller) {
117+
while (true) {
118+
const { done, value } = await reader.read();
119+
if (done) {
120+
break;
121+
}
122+
downloadedBytes += value.length;
123+
const progress = ((downloadedBytes / totalBytes) * 100).toFixed(0);
124+
loader?.message(
125+
`Downloading cached build from ${name} (${progress}% of ${totalMB} MB)`
126+
);
127+
controller.enqueue(value);
128+
}
129+
controller.close();
130+
},
131+
});
132+
responseData = new Response(stream);
133+
}
134+
135+
const zipPath = localArtifactPath + '.zip';
136+
const buffer = await responseData.arrayBuffer();
137+
fs.writeFileSync(zipPath, new Uint8Array(buffer));
138+
unzipFile(zipPath, localArtifactPath);
139+
fs.unlinkSync(zipPath);
140+
} catch (error) {
141+
loader?.stop(`Failed: Downloading cached build from ${name}`);
142+
throw new RnefError(`Unexpected error`, { cause: error });
143+
}
144+
}
145+
146+
function unzipFile(zipPath: string, targetPath: string): void {
147+
const zip = new AdmZip(zipPath);
148+
zip.extractAllTo(targetPath, true);
149+
}
150+
151+
async function extractArtifactTarballIfNeeded(artifactPath: string) {
152+
const tarPath = path.join(artifactPath, 'app.tar.gz');
153+
154+
// If the tarball is not found, it means the artifact is already unpacked.
155+
if (!fs.existsSync(tarPath)) {
156+
return;
157+
}
158+
159+
// iOS simulator build artifact (*.app directory) is packed in .tar.gz file to
160+
// preserve execute file permission.
161+
// See: https://github.com/actions/upload-artifact?tab=readme-ov-file#permission-loss
162+
await tar.extract({
163+
file: tarPath,
164+
cwd: artifactPath,
165+
gzip: true,
166+
});
167+
fs.unlinkSync(tarPath);
168+
}

0 commit comments

Comments
 (0)