@touk/federated-types 1.3.0-beta.6 → 1.3.0-beta.8

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 (3) hide show
  1. package/README.md +14 -0
  2. package/cli.js +136 -9
  3. package/package.json +2 -3
package/README.md CHANGED
@@ -82,6 +82,20 @@ scripts: {
82
82
  }
83
83
  ```
84
84
 
85
+ ## Pruning unreachable modules
86
+
87
+ By default the generated typings contain every module of the TypeScript program built from your exposes, including modules that are only used by their implementation. Pass `--prune` to keep only the modules reachable from the exposed modules through their types (`import`/`export ... from`, `import("...")`, `import "..."`, `require("...")`):
88
+
89
+ ```javascript
90
+ //package.json
91
+
92
+ scripts: {
93
+ "make-types": "make-federated-types --prune"
94
+ }
95
+ ```
96
+
97
+ Modules that augment the global scope (`declare global`) or other modules are always kept. With `--prune`, non-exposed modules can no longer be imported by their deep name (e.g. `myApp/utils/internal`) unless they are reachable from an exposed module.
98
+
85
99
  ## Inline configuration (without config file)
86
100
 
87
101
  If you prefer not to maintain a separate `federation.config.json` file, you can provide the configuration directly via CLI arguments using `--name` and `--exposes`:
package/cli.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
- const findNodeModules = require('find-node-modules');
6
5
  const ts = require('typescript');
7
6
  const chalk = require('chalk');
8
7
 
@@ -20,7 +19,20 @@ function reportDiagnostic(diagnostic) {
20
19
  );
21
20
  }
22
21
 
23
- const [nodeModules] = findNodeModules({ cwd: process.argv[1], relative: false });
22
+ // nearest node_modules directory up from the CLI script
23
+ const findNodeModules = (start) => {
24
+ let dir = path.resolve(start);
25
+ while (!fs.existsSync(path.join(dir, 'node_modules'))) {
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir) {
28
+ return path.resolve('node_modules');
29
+ }
30
+ dir = parent;
31
+ }
32
+ return path.join(dir, 'node_modules');
33
+ };
34
+
35
+ const nodeModules = findNodeModules(process.argv[1]);
24
36
 
25
37
  const hasArg = (argName) => {
26
38
  const argIndex = process.argv.indexOf(argName);
@@ -68,6 +80,7 @@ const configPath = configPathArg ? path.resolve(configPathArg) : null;
68
80
  const nameArg = getArg('--name');
69
81
  const exposesArgs = getAllArgs('--exposes');
70
82
  const verboseArg = hasArg('--verbose') || process.env.npm_config_loglevel === 'verbose';
83
+ const pruneArg = hasArg('--prune');
71
84
 
72
85
  const log = (...args) => console.log(...args);
73
86
  const logDebug = (...args) => verboseArg && console.debug(chalk.gray.dim(...args));
@@ -155,6 +168,102 @@ function getModuleDeclareName(exposeName) {
155
168
  return path.join(federationConfig.name, exposeName).replace(/[\\/]/g, '/');
156
169
  }
157
170
 
171
+ const toPosixPath = (value) => value.replace(/[\\/]/g, '/');
172
+ const stripSourceExtension = (file) => file.replace(/\.(d\.ts|[cm]?tsx?|[cm]?jsx?)$/, '');
173
+
174
+ /**
175
+ * Maps the absolute path (without extension) of every source file in the program to the module
176
+ * name tsc gives it in an outFile bundle: its path relative to the common source directory.
177
+ * Exposes outside of the exposing package (e.g. a package importing a sibling's sources) move
178
+ * that directory up, so the module name cannot be derived from the configured path alone.
179
+ */
180
+ function getEmittedModuleNames(program) {
181
+ // internal, but stable TypeScript API; fall back to suffix matching without it
182
+ if (typeof program.getCommonSourceDirectory !== 'function') {
183
+ return new Map();
184
+ }
185
+ const commonSourceDirectory = program.getCommonSourceDirectory();
186
+ return new Map(
187
+ program
188
+ .getSourceFiles()
189
+ .filter((sourceFile) => !sourceFile.isDeclarationFile)
190
+ .map((sourceFile) => {
191
+ const absolutePath = stripSourceExtension(path.resolve(sourceFile.fileName));
192
+ return [
193
+ toPosixPath(absolutePath),
194
+ toPosixPath(path.relative(commonSourceDirectory, absolutePath)),
195
+ ];
196
+ })
197
+ );
198
+ }
199
+
200
+ // Matches the module specifier of `from "x"`, `import("x")`, `import "x"` and `require("x")`.
201
+ const moduleReferenceRegex = /\b(?:from|import|require)\s*\(?\s*(["'])([^"'\n]+)\1/g;
202
+ // Global or module augmentations take effect without being imported, so such modules are always kept.
203
+ // Inside a `declare module` block tsc emits them without the `declare` keyword.
204
+ const augmentationRegex = /^\s+(?:declare\s+)?(?:global\s*\{|module\s+["'])/m;
205
+
206
+ /**
207
+ * Drops the top-level `declare module` blocks that cannot be reached from the exposed modules
208
+ * through module references. Text outside the blocks is kept as is.
209
+ */
210
+ function pruneUnreachableModules(typing, isExposedModule) {
211
+ const blockRegex = /^declare module "([^"]+)" \{\n[\s\S]*?^\}\n?/gm;
212
+ const segments = [];
213
+ const modules = new Map();
214
+ let lastIndex = 0;
215
+ let match;
216
+
217
+ while ((match = blockRegex.exec(typing)) !== null) {
218
+ segments.push(typing.slice(lastIndex, match.index));
219
+ const block = { name: match[1], text: match[0] };
220
+ segments.push(block);
221
+ modules.set(block.name, block);
222
+ lastIndex = blockRegex.lastIndex;
223
+ }
224
+ segments.push(typing.slice(lastIndex));
225
+
226
+ const roots = Array.from(modules.values()).filter(
227
+ (block) => isExposedModule(block.name) || augmentationRegex.test(block.text)
228
+ );
229
+
230
+ if (!roots.some((block) => isExposedModule(block.name))) {
231
+ console.warn(
232
+ chalk.yellow('WARNING:'),
233
+ 'No exposed module found in the emitted typings, skipping --prune'
234
+ );
235
+ return typing;
236
+ }
237
+
238
+ const reachable = new Set();
239
+ const queue = [];
240
+ const visit = (text) => {
241
+ for (const [, , name] of text.matchAll(moduleReferenceRegex)) {
242
+ const block = modules.get(name);
243
+ if (block && !reachable.has(block)) {
244
+ reachable.add(block);
245
+ queue.push(block);
246
+ }
247
+ }
248
+ };
249
+
250
+ segments.filter((segment) => typeof segment === 'string').forEach(visit);
251
+ roots.forEach((block) => {
252
+ reachable.add(block);
253
+ queue.push(block);
254
+ });
255
+ while (queue.length > 0) {
256
+ visit(queue.pop().text);
257
+ }
258
+
259
+ logDebug(`--prune: kept ${reachable.size} of ${modules.size} modules`);
260
+
261
+ return segments
262
+ .filter((segment) => typeof segment === 'string' || reachable.has(segment))
263
+ .map((segment) => (typeof segment === 'string' ? segment : segment.text))
264
+ .join('');
265
+ }
266
+
158
267
  try {
159
268
  fs.mkdirSync(outputDir, { recursive: true });
160
269
  const outFile = path.resolve(outputDir, `${federationConfig.name}.d.ts`);
@@ -184,10 +293,30 @@ try {
184
293
  let typing = fs.readFileSync(outFile, { encoding: 'utf8', flag: 'r' });
185
294
 
186
295
  const moduleRegex = RegExp(/declare module "(.*)"/, 'g');
187
- const exposedPaths = compileKeys.map((key) => [
188
- key,
189
- federationConfig.exposes[key].replace(/\.(tsx?|jsx?)$/, ''),
190
- ]);
296
+ const emittedModuleNames = getEmittedModuleNames(program);
297
+ const exposedModules = compileKeys.map((key) => {
298
+ const exposePath = federationConfig.exposes[key];
299
+ const absolutePath = toPosixPath(stripSourceExtension(path.resolve(exposePath)));
300
+ return {
301
+ key,
302
+ relativePath: exposePath.replace(/\.(tsx?|jsx?)$/, ''),
303
+ moduleName:
304
+ emittedModuleNames.get(absolutePath) ??
305
+ emittedModuleNames.get(`${absolutePath}/index`),
306
+ };
307
+ });
308
+ // Falls back to suffix matching of the configured path when the source file could not be
309
+ // resolved in the program.
310
+ const findExposeKeys = (name) =>
311
+ exposedModules
312
+ .filter(({ relativePath, moduleName }) =>
313
+ moduleName !== undefined ? moduleName === name : relativePath.endsWith(name)
314
+ )
315
+ .map(({ key }) => key);
316
+
317
+ if (pruneArg) {
318
+ typing = pruneUnreachableModules(typing, (name) => findExposeKeys(name).length > 0);
319
+ }
191
320
 
192
321
  // module name emitted by tsc -> name it is declared under in the federated typings
193
322
  const renames = new Map();
@@ -201,9 +330,7 @@ try {
201
330
  }
202
331
 
203
332
  // exposeName - relative name of exposed component (if not found - just take moduleName)
204
- const [exposeName = name, ...aliases] = exposedPaths
205
- .filter(([, normalizedPath]) => normalizedPath.endsWith(name))
206
- .map(([key]) => key);
333
+ const [exposeName = name, ...aliases] = findExposeKeys(name);
207
334
 
208
335
  const moduleDeclareName = getModuleDeclareName(exposeName);
209
336
  renames.set(name, moduleDeclareName);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@touk/federated-types",
3
3
  "description": "share typings for your federated typescript packages around your mono-repo",
4
- "version": "1.3.0-beta.6",
4
+ "version": "1.3.0-beta.8",
5
5
  "author": "Julian Wielga <j@touk.pl>",
6
6
  "bin": {
7
7
  "make-federated-types": "./cli.js"
@@ -10,8 +10,7 @@
10
10
  "Jonathan Greene <jgreene@pixability.com>"
11
11
  ],
12
12
  "dependencies": {
13
- "chalk": "^4.1.2",
14
- "find-node-modules": "2.1.3"
13
+ "chalk": "^4.1.2"
15
14
  },
16
15
  "devDependencies": {
17
16
  "@commitlint/cli": "^20.4.2",