@expo/fingerprint 0.0.1

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.
Files changed (84) hide show
  1. package/.eslintignore +1 -0
  2. package/LICENSE +22 -0
  3. package/README.md +118 -0
  4. package/__mocks__/fs/promises.ts +2 -0
  5. package/__mocks__/fs.ts +2 -0
  6. package/babel.config.js +6 -0
  7. package/bin/cli.js +27 -0
  8. package/build/Dedup.d.ts +9 -0
  9. package/build/Dedup.js +83 -0
  10. package/build/Dedup.js.map +1 -0
  11. package/build/Fingerprint.d.ts +13 -0
  12. package/build/Fingerprint.js +42 -0
  13. package/build/Fingerprint.js.map +1 -0
  14. package/build/Fingerprint.types.d.ts +78 -0
  15. package/build/Fingerprint.types.js +4 -0
  16. package/build/Fingerprint.types.js.map +1 -0
  17. package/build/Options.d.ts +2 -0
  18. package/build/Options.js +23 -0
  19. package/build/Options.js.map +1 -0
  20. package/build/Sort.d.ts +2 -0
  21. package/build/Sort.js +27 -0
  22. package/build/Sort.js.map +1 -0
  23. package/build/hash/Hash.d.ts +28 -0
  24. package/build/hash/Hash.js +166 -0
  25. package/build/hash/Hash.js.map +1 -0
  26. package/build/index.d.ts +2 -0
  27. package/build/index.js +22 -0
  28. package/build/index.js.map +1 -0
  29. package/build/sourcer/Bare.d.ts +6 -0
  30. package/build/sourcer/Bare.js +107 -0
  31. package/build/sourcer/Bare.js.map +1 -0
  32. package/build/sourcer/Expo.d.ts +5 -0
  33. package/build/sourcer/Expo.js +175 -0
  34. package/build/sourcer/Expo.js.map +1 -0
  35. package/build/sourcer/PatchPackage.d.ts +2 -0
  36. package/build/sourcer/PatchPackage.js +19 -0
  37. package/build/sourcer/PatchPackage.js.map +1 -0
  38. package/build/sourcer/Sourcer.d.ts +2 -0
  39. package/build/sourcer/Sourcer.js +42 -0
  40. package/build/sourcer/Sourcer.js.map +1 -0
  41. package/build/sourcer/Utils.d.ts +2 -0
  42. package/build/sourcer/Utils.js +25 -0
  43. package/build/sourcer/Utils.js.map +1 -0
  44. package/build/utils/Profile.d.ts +8 -0
  45. package/build/utils/Profile.js +43 -0
  46. package/build/utils/Profile.js.map +1 -0
  47. package/e2e/__tests__/__snapshots__/managed-test.ts.snap +200 -0
  48. package/e2e/__tests__/bare-test.ts +66 -0
  49. package/e2e/__tests__/managed-test.ts +162 -0
  50. package/e2e/jest.config.js +10 -0
  51. package/jest.config.js +10 -0
  52. package/package.json +70 -0
  53. package/scripts/createFixture.ts +81 -0
  54. package/src/Dedup.ts +97 -0
  55. package/src/Fingerprint.ts +51 -0
  56. package/src/Fingerprint.types.ts +98 -0
  57. package/src/Options.ts +18 -0
  58. package/src/Sort.ts +22 -0
  59. package/src/__tests__/Dedup-test.ts +177 -0
  60. package/src/__tests__/Fingerprint-test.ts +116 -0
  61. package/src/__tests__/Sort-test.ts +56 -0
  62. package/src/hash/Hash.ts +204 -0
  63. package/src/hash/__tests__/Hash-test.ts +192 -0
  64. package/src/index.ts +3 -0
  65. package/src/sourcer/Bare.ts +114 -0
  66. package/src/sourcer/Expo.ts +216 -0
  67. package/src/sourcer/PatchPackage.ts +18 -0
  68. package/src/sourcer/Sourcer.ts +58 -0
  69. package/src/sourcer/Utils.ts +23 -0
  70. package/src/sourcer/__tests__/Bare-test.ts +59 -0
  71. package/src/sourcer/__tests__/Expo-test.ts +265 -0
  72. package/src/sourcer/__tests__/PatchPackage-test.ts +54 -0
  73. package/src/sourcer/__tests__/Sourcer-test.ts +14 -0
  74. package/src/sourcer/__tests__/__snapshots__/Bare-test.ts.snap +29 -0
  75. package/src/sourcer/__tests__/__snapshots__/Expo-test.ts.snap +139 -0
  76. package/src/sourcer/__tests__/fixtures/BareReactNative70Project.json +47 -0
  77. package/src/sourcer/__tests__/fixtures/ExpoAutolinkingAndroid.json +82 -0
  78. package/src/sourcer/__tests__/fixtures/ExpoAutolinkingIos.json +114 -0
  79. package/src/sourcer/__tests__/fixtures/ExpoManaged47Project.json +6 -0
  80. package/src/sourcer/__tests__/fixtures/PatchPackage.json +4 -0
  81. package/src/sourcer/__tests__/fixtures/RncliAutoLinking.json +165 -0
  82. package/src/utils/Profile.ts +47 -0
  83. package/src/utils/__tests__/Profile-test.ts +11 -0
  84. package/tsconfig.json +9 -0
@@ -0,0 +1,204 @@
1
+ import { createHash } from 'crypto';
2
+ import { createReadStream } from 'fs';
3
+ import fs from 'fs/promises';
4
+ import minimatch from 'minimatch';
5
+ import pLimit from 'p-limit';
6
+ import path from 'path';
7
+
8
+ import type {
9
+ Fingerprint,
10
+ FingerprintSource,
11
+ HashResult,
12
+ HashSource,
13
+ HashSourceContents,
14
+ NormalizedOptions,
15
+ } from '../Fingerprint.types';
16
+ import { profile } from '../utils/Profile';
17
+
18
+ /**
19
+ * Create a `Fingerprint` from `HashSources` array
20
+ */
21
+ export async function createFingerprintFromSourcesAsync(
22
+ sources: HashSource[],
23
+ projectRoot: string,
24
+ options: NormalizedOptions
25
+ ): Promise<Fingerprint> {
26
+ const limiter = pLimit(options.concurrentIoLimit);
27
+ const fingerprintSources = await Promise.all(
28
+ sources.map((source) => createFingerprintSourceAsync(source, limiter, projectRoot, options))
29
+ );
30
+
31
+ const hasher = createHash(options.hashAlgorithm);
32
+ for (const source of fingerprintSources) {
33
+ if (source.hash != null) {
34
+ hasher.update(createSourceId(source));
35
+ hasher.update(source.hash);
36
+ }
37
+ }
38
+ const hash = hasher.digest('hex');
39
+
40
+ return {
41
+ sources: fingerprintSources,
42
+ hash,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Create a `FingerprintSource` from a `HashSource`
48
+ * This function will get a hash value and merge back to original source
49
+ */
50
+ export async function createFingerprintSourceAsync(
51
+ source: HashSource,
52
+ limiter: pLimit.Limit,
53
+ projectRoot: string,
54
+ options: NormalizedOptions
55
+ ): Promise<FingerprintSource> {
56
+ let result: HashResult | null = null;
57
+ switch (source.type) {
58
+ case 'contents':
59
+ result = await createContentsHashResultsAsync(source, options);
60
+ break;
61
+ case 'file':
62
+ result = await createFileHashResultsAsync(source.filePath, limiter, projectRoot, options);
63
+ break;
64
+ case 'dir':
65
+ result = await profile(
66
+ createDirHashResultsAsync,
67
+ `createDirHashResultsAsync(${source.filePath})`
68
+ )(source.filePath, limiter, projectRoot, options);
69
+ break;
70
+ default:
71
+ throw new Error('Unsupported source type');
72
+ }
73
+
74
+ return { ...source, hash: result?.hex ?? null };
75
+ }
76
+
77
+ /**
78
+ * Create a `HashResult` from a file
79
+ */
80
+ export async function createFileHashResultsAsync(
81
+ filePath: string,
82
+ limiter: pLimit.Limit,
83
+ projectRoot: string,
84
+ options: NormalizedOptions
85
+ ): Promise<HashResult> {
86
+ // Backup code for faster hashing
87
+ /*
88
+ return limiter(async () => {
89
+ const hasher = createHash(options.hashAlgorithm);
90
+
91
+ const stat = await fs.stat(filePath);
92
+ hasher.update(`${stat.size}`);
93
+
94
+ const buffer = Buffer.alloc(4096);
95
+ const fd = await fs.open(filePath, 'r');
96
+ await fd.read(buffer, 0, buffer.length, 0);
97
+ await fd.close();
98
+ hasher.update(buffer);
99
+ console.log('stat', filePath, stat.size);
100
+ return { id: path.relative(projectRoot, filePath), hex: hasher.digest('hex') };
101
+ });
102
+ */
103
+
104
+ return limiter(() => {
105
+ return new Promise<HashResult>((resolve, reject) => {
106
+ let resolved = false;
107
+ const hasher = createHash(options.hashAlgorithm);
108
+ const stream = createReadStream(path.join(projectRoot, filePath));
109
+ stream.on('close', () => {
110
+ if (!resolved) {
111
+ const hex = hasher.digest('hex');
112
+ resolve({ id: filePath, hex });
113
+ resolved = true;
114
+ }
115
+ });
116
+ stream.on('error', (e) => {
117
+ reject(e);
118
+ });
119
+ stream.on('data', (chunk) => {
120
+ hasher.update(chunk);
121
+ });
122
+ });
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Indicate the given `dirPath` should be excluded by `dirExcludes`
128
+ */
129
+ function isExcludedDir(dirPath: string, dirExcludes: string[]): boolean {
130
+ for (const exclude of dirExcludes) {
131
+ if (minimatch(dirPath, exclude)) {
132
+ return true;
133
+ }
134
+ }
135
+ return false;
136
+ }
137
+
138
+ /**
139
+ * Create `HashResult` for a dir.
140
+ * If the dir is excluded, returns null rather than a HashResult
141
+ */
142
+ export async function createDirHashResultsAsync(
143
+ dirPath: string,
144
+ limiter: pLimit.Limit,
145
+ projectRoot: string,
146
+ options: NormalizedOptions,
147
+ depth: number = 0
148
+ ): Promise<HashResult | null> {
149
+ if (isExcludedDir(dirPath, options.dirExcludes)) {
150
+ return null;
151
+ }
152
+ const dirents = (await fs.readdir(path.join(projectRoot, dirPath), { withFileTypes: true })).sort(
153
+ (a, b) => a.name.localeCompare(b.name)
154
+ );
155
+ const promises: Promise<HashResult | null>[] = [];
156
+ for (const dirent of dirents) {
157
+ if (dirent.isDirectory()) {
158
+ const filePath = path.join(dirPath, dirent.name);
159
+ promises.push(createDirHashResultsAsync(filePath, limiter, projectRoot, options, depth + 1));
160
+ } else if (dirent.isFile()) {
161
+ const filePath = path.join(dirPath, dirent.name);
162
+ promises.push(createFileHashResultsAsync(filePath, limiter, projectRoot, options));
163
+ }
164
+ }
165
+
166
+ const hasher = createHash(options.hashAlgorithm);
167
+ const results = await Promise.all(promises);
168
+ for (const result of results) {
169
+ if (result != null) {
170
+ hasher.update(result.id);
171
+ hasher.update(result.hex);
172
+ }
173
+ }
174
+ const hex = hasher.digest('hex');
175
+
176
+ return { id: dirPath, hex };
177
+ }
178
+
179
+ /**
180
+ * Create `HashResult` for a `HashSourceContents`
181
+ */
182
+ export async function createContentsHashResultsAsync(
183
+ source: HashSourceContents,
184
+ options: NormalizedOptions
185
+ ): Promise<HashResult> {
186
+ const hex = createHash(options.hashAlgorithm).update(source.contents).digest('hex');
187
+ return { id: source.id, hex };
188
+ }
189
+
190
+ /**
191
+ * Create id from given source
192
+ */
193
+ export function createSourceId(source: HashSource): string {
194
+ switch (source.type) {
195
+ case 'contents':
196
+ return source.id;
197
+ case 'file':
198
+ return source.filePath;
199
+ case 'dir':
200
+ return source.filePath;
201
+ default:
202
+ throw new Error('Unsupported source type');
203
+ }
204
+ }
@@ -0,0 +1,192 @@
1
+ import { createHash } from 'crypto';
2
+ import { vol } from 'memfs';
3
+ import pLimit from 'p-limit';
4
+ import path from 'path';
5
+
6
+ import { HashSource } from '../../Fingerprint.types';
7
+ import { normalizeOptions } from '../../Options';
8
+ import {
9
+ createContentsHashResultsAsync,
10
+ createDirHashResultsAsync,
11
+ createFileHashResultsAsync,
12
+ createFingerprintFromSourcesAsync,
13
+ createFingerprintSourceAsync,
14
+ createSourceId,
15
+ } from '../Hash';
16
+
17
+ jest.mock('fs');
18
+ jest.mock('fs/promises');
19
+
20
+ describe(createFingerprintFromSourcesAsync, () => {
21
+ afterEach(() => {
22
+ vol.reset();
23
+ });
24
+
25
+ it('snapshot', async () => {
26
+ vol.mkdirSync('/app');
27
+ vol.writeFileSync(path.join('/app', 'app.json'), '{}');
28
+
29
+ const sources: HashSource[] = [
30
+ { type: 'contents', id: 'foo', contents: 'HelloWorld', reasons: ['foo'] },
31
+ { type: 'file', filePath: 'app.json', reasons: ['expoConfig'] },
32
+ ];
33
+
34
+ expect(await createFingerprintFromSourcesAsync(sources, '/app', normalizeOptions()))
35
+ .toMatchInlineSnapshot(`
36
+ Object {
37
+ "hash": "ec7d81780f735d5e289b27cdcc04a6c99d2621dc",
38
+ "sources": Array [
39
+ Object {
40
+ "contents": "HelloWorld",
41
+ "hash": "db8ac1c259eb89d4a131b253bacfca5f319d54f2",
42
+ "id": "foo",
43
+ "reasons": Array [
44
+ "foo",
45
+ ],
46
+ "type": "contents",
47
+ },
48
+ Object {
49
+ "filePath": "app.json",
50
+ "hash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f",
51
+ "reasons": Array [
52
+ "expoConfig",
53
+ ],
54
+ "type": "file",
55
+ },
56
+ ],
57
+ }
58
+ `);
59
+ });
60
+ });
61
+
62
+ describe(createFingerprintSourceAsync, () => {
63
+ it('should merge hash value to original source', async () => {
64
+ const source: HashSource = {
65
+ type: 'contents',
66
+ id: 'foo',
67
+ contents: 'HelloWorld',
68
+ reasons: ['foo'],
69
+ };
70
+ const expectedResult = {
71
+ ...source,
72
+ hash: 'db8ac1c259eb89d4a131b253bacfca5f319d54f2',
73
+ };
74
+ expect(
75
+ await createFingerprintSourceAsync(source, pLimit(1), '/app', normalizeOptions())
76
+ ).toEqual(expectedResult);
77
+ });
78
+ });
79
+
80
+ describe(createContentsHashResultsAsync, () => {
81
+ it('should return {id, hex} result', async () => {
82
+ const id = 'foo';
83
+ const contents = '{}';
84
+ const options = normalizeOptions();
85
+ const result = await createContentsHashResultsAsync(
86
+ {
87
+ type: 'contents',
88
+ id,
89
+ contents,
90
+ reasons: [id],
91
+ },
92
+ options
93
+ );
94
+
95
+ const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex');
96
+ expect(result.id).toEqual(id);
97
+ expect(result.hex).toEqual(expectHex);
98
+ });
99
+ });
100
+
101
+ describe(createFileHashResultsAsync, () => {
102
+ afterEach(() => {
103
+ vol.reset();
104
+ });
105
+
106
+ it('should return {id, hex} result', async () => {
107
+ const filePath = 'app.json';
108
+ const contents = '{}';
109
+ const limiter = pLimit(1);
110
+ const options = normalizeOptions();
111
+ vol.mkdirSync('/app');
112
+ vol.writeFileSync(path.join('/app', filePath), contents);
113
+
114
+ const result = await createFileHashResultsAsync(filePath, limiter, '/app', options);
115
+
116
+ const expectHex = createHash(options.hashAlgorithm).update(contents).digest('hex');
117
+ expect(result.id).toEqual(filePath);
118
+ expect(result.hex).toEqual(expectHex);
119
+ });
120
+ });
121
+
122
+ describe(createDirHashResultsAsync, () => {
123
+ afterEach(() => {
124
+ vol.reset();
125
+ });
126
+
127
+ it('should return {id, hex} result', async () => {
128
+ const limiter = pLimit(3);
129
+ const options = normalizeOptions();
130
+ const volJSON = {
131
+ '/app/ios/Podfile': '...',
132
+ '/app/eas.json': '{}',
133
+ '/app/app.json': '{}',
134
+ '/app/android/build.gradle': '...',
135
+ };
136
+ vol.fromJSON(volJSON);
137
+ const result = await createDirHashResultsAsync('.', limiter, '/app', options);
138
+
139
+ expect(result?.id).toEqual('.');
140
+ expect(result?.hex).not.toBe('');
141
+ });
142
+
143
+ it('should return stable result from sorted files', async () => {
144
+ const limiter = pLimit(3);
145
+ const options = normalizeOptions();
146
+ const volJSON = {
147
+ '/app/ios/Podfile': '...',
148
+ '/app/eas.json': '{}',
149
+ '/app/app.json': '{}',
150
+ '/app/android/build.gradle': '...',
151
+ };
152
+ vol.fromJSON(volJSON);
153
+ const result = await createDirHashResultsAsync('.', limiter, '/app', options);
154
+
155
+ vol.reset();
156
+ const sortedVolJSON = {
157
+ '/app/app.json': '{}',
158
+ '/app/eas.json': '{}',
159
+ '/app/android/build.gradle': '...',
160
+ '/app/ios/Podfile': '...',
161
+ };
162
+ vol.fromJSON(sortedVolJSON);
163
+ const sortedResult = await createDirHashResultsAsync('.', limiter, '/app', options);
164
+
165
+ expect(result?.id).toEqual(sortedResult?.id);
166
+ expect(result?.hex).toEqual(sortedResult?.hex);
167
+ });
168
+ });
169
+
170
+ describe(createSourceId, () => {
171
+ it(`should use filePath as id for file or dir`, () => {
172
+ const fileSource: HashSource = {
173
+ type: 'file',
174
+ filePath: '/app/app.json',
175
+ reasons: ['expoConfig'],
176
+ };
177
+ expect(createSourceId(fileSource)).toBe('/app/app.json');
178
+
179
+ const dirSource: HashSource = { type: 'dir', filePath: '/app/ios', reasons: ['bareNativeDir'] };
180
+ expect(createSourceId(dirSource)).toBe('/app/ios');
181
+ });
182
+
183
+ it(`should use given id for contents`, () => {
184
+ const source: HashSource = {
185
+ type: 'contents',
186
+ id: 'foo',
187
+ contents: 'HelloWorld',
188
+ reasons: ['foo'],
189
+ };
190
+ expect(createSourceId(source)).toBe('foo');
191
+ });
192
+ });
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { createFingerprintAsync, createProjectHashAsync } from './Fingerprint';
2
+
3
+ export * from './Fingerprint.types';
@@ -0,0 +1,114 @@
1
+ import spawnAsync from '@expo/spawn-async';
2
+ import chalk from 'chalk';
3
+ import path from 'path';
4
+
5
+ import type { HashSource, NormalizedOptions, Platform } from '../Fingerprint.types';
6
+ import { getFileBasedHashSourceAsync } from './Utils';
7
+
8
+ const debug = require('debug')('expo:fingerprint:sourcer:Bare');
9
+
10
+ export async function getBareAndroidSourcesAsync(
11
+ projectRoot: string,
12
+ options: NormalizedOptions
13
+ ): Promise<HashSource[]> {
14
+ if (options.platforms.includes('android')) {
15
+ const result = await getFileBasedHashSourceAsync(projectRoot, 'android', 'bareNativeDir');
16
+ if (result != null) {
17
+ debug(`Adding bare native dir - ${chalk.dim('android')}`);
18
+ return [result];
19
+ }
20
+ }
21
+ return [];
22
+ }
23
+
24
+ export async function getBareIosSourcesAsync(
25
+ projectRoot: string,
26
+ options: NormalizedOptions
27
+ ): Promise<HashSource[]> {
28
+ if (options.platforms.includes('ios')) {
29
+ const result = await getFileBasedHashSourceAsync(projectRoot, 'ios', 'bareNativeDir');
30
+ if (result != null) {
31
+ debug(`Adding bare native dir - ${chalk.dim('ios')}`);
32
+ return [result];
33
+ }
34
+ }
35
+ return [];
36
+ }
37
+
38
+ export async function getPackageJsonScriptSourcesAsync(
39
+ projectRoot: string,
40
+ options: NormalizedOptions
41
+ ) {
42
+ let packageJson;
43
+ try {
44
+ packageJson = require(`${projectRoot}/package.json`);
45
+ } catch (e: unknown) {
46
+ debug(`Unable to read package.json from ${projectRoot}/package.json: ` + e);
47
+ return [];
48
+ }
49
+ const results: HashSource[] = [];
50
+ if (packageJson.scripts) {
51
+ debug(`Adding package.json contents - ${chalk.dim('scripts')}`);
52
+ const id = 'packageJson:scripts';
53
+ results.push({
54
+ type: 'contents',
55
+ id,
56
+ contents: JSON.stringify(packageJson.scripts),
57
+ reasons: [id],
58
+ });
59
+ }
60
+ return results;
61
+ }
62
+
63
+ export async function getGitIgnoreSourcesAsync(projectRoot: string, options: NormalizedOptions) {
64
+ const result = await getFileBasedHashSourceAsync(projectRoot, '.gitignore', 'bareGitIgnore');
65
+ if (result != null) {
66
+ debug(`Adding file - ${chalk.dim('.gitignore')}`);
67
+ return [result];
68
+ }
69
+ return [];
70
+ }
71
+
72
+ export async function getRncliAutolinkingSourcesAsync(
73
+ projectRoot: string,
74
+ options: NormalizedOptions
75
+ ): Promise<HashSource[]> {
76
+ try {
77
+ const results: HashSource[] = [];
78
+ const { stdout } = await spawnAsync('npx', ['react-native', 'config'], { cwd: projectRoot });
79
+ const config = JSON.parse(stdout);
80
+ const { root } = config;
81
+ const reasons = ['bareRncliAutolinking'];
82
+ for (const depData of Object.values<any>(config.dependencies)) {
83
+ const filePath = path.relative(root, depData.root);
84
+ results.push({ type: 'dir', filePath, reasons });
85
+ debug(`Adding react-native-cli autolinking dir - ${chalk.dim(filePath)}`);
86
+ for (const platform of options.platforms) {
87
+ const platformData = getRncliPlatformData(depData, root, platform);
88
+ if (platformData) {
89
+ results.push({
90
+ type: 'contents',
91
+ id: `rncliAutolinkingConfig:${depData.name}:${platform}`,
92
+ contents: platformData,
93
+ reasons,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ return results;
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ function getRncliPlatformData(dependency: any, root: string, platform: Platform): string {
105
+ const platformData = dependency.platforms[platform];
106
+ if (!platformData) {
107
+ return '';
108
+ }
109
+ const json: Record<string, string> = {};
110
+ for (const [key, value] of Object.entries<any>(platformData)) {
111
+ json[key] = value?.startsWith?.(root) ? path.relative(root, value) : value;
112
+ }
113
+ return JSON.stringify(json);
114
+ }