Skip to content

Commit ab3a90f

Browse files
authored
chore(core): enable package exports in smithy/core (#1281)
* chore(core): enable package exports in smithy/core * add CLI dispatcher * add cbor submodule stub, update Inliner.js script * enforce files field in package.json files * chore(core): move core components to core submodules * add readme for core submodules * linting * accidental recursion * restore existing core/src * remove unused metadata * formatting * handle non-folder in submodules * pare down metadata * allow non-submodules in core
1 parent be063c6 commit ab3a90f

File tree

19 files changed

+571
-16
lines changed

19 files changed

+571
-16
lines changed

.changeset/thin-adults-exist.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@smithy/core": minor
3+
---
4+
5+
enable package.json exports in core

packages/core/README.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,41 @@
55

66
> An internal package. You probably shouldn't use this package, at least directly.
77
8-
## Usage
9-
108
This package provides common or core functionality for generic Smithy clients.
119

1210
You do not need to explicitly install this package, since it will be installed during code generation if used.
11+
12+
## Development of `@smithy/core` submodules
13+
14+
Core submodules are organized for distribution via the `package.json` `exports` field.
15+
16+
`exports` is supported by default by the latest Node.js, webpack, and esbuild. For react-native, it can be
17+
enabled via instructions found at [reactnative.dev/blog](https://reactnative.dev/blog/2023/06/21/package-exports-support), but we also provide a compatibility redirect.
18+
19+
Think of `@smithy/core` as a mono-package within the monorepo.
20+
It preserves the benefits of modularization, for example to optimize Node.js initialization speed,
21+
while making it easier to have a consistent version of core dependencies, reducing package sprawl when
22+
installing a Smithy runtime client.
23+
24+
### Guide for submodules
25+
26+
- Each `index.ts` file corresponding to the pattern `./src/submodules/<MODULE_NAME>/index.ts` will be
27+
published as a separate `dist-cjs` bundled submodule index using the `Inliner.js` build script.
28+
- create a folder as `./src/submodules/<SUBMODULE>` including an `index.ts` file and a `README.md` file.
29+
- The linter will throw an error on missing submodule metadata in `package.json` and the various `tsconfig.json` files, but it will automatically fix them if possible.
30+
- a submodule is equivalent to a standalone `@smithy/<pkg>` package in that importing it in Node.js will resolve a separate bundle.
31+
- submodules may not relatively import files from other submodules. Instead, directly use the `@scope/pkg/submodule` name as the import.
32+
- The linter will check for this and throw an error.
33+
- To the extent possible, correctly declaring submodule metadata is validated by the linter in `@smithy/core`.
34+
The linter runs during `yarn build` and also as `yarn lint`.
35+
36+
### When should I create an `@smithy/core/submodule` vs. `@smithy/new-package`?
37+
38+
Keep in mind that the core package is installed by all downstream clients.
39+
40+
If the component functionality is upstream of multiple clients, it is
41+
a good candidate for a core submodule. For example, if `middleware-retry` had been written
42+
after the support for submodules was added, it would have been a submodule.
43+
44+
If the component's functionality is downstream of a client (rare), or only expected to be used by a very small
45+
subset of clients, it could be written as a standalone package.

packages/core/package.json

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,33 @@
22
"name": "@smithy/core",
33
"version": "2.0.1",
44
"scripts": {
5-
"build": "concurrently 'yarn:build:cjs' 'yarn:build:es' 'yarn:build:types && yarn build:types:downlevel'",
5+
"build": "yarn lint && concurrently 'yarn:build:cjs' 'yarn:build:es' 'yarn:build:types && yarn build:types:downlevel'",
66
"build:cjs": "node ../../scripts/inline core",
77
"build:es": "yarn g:tsc -p tsconfig.es.json",
88
"build:types": "yarn g:tsc -p tsconfig.types.json",
99
"build:types:downlevel": "downlevel-dts dist-types dist-types/ts3.4",
1010
"stage-release": "rimraf ./.release && yarn pack && mkdir ./.release && tar zxvf ./package.tgz --directory ./.release && rm ./package.tgz",
1111
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo || exit 0",
12-
"lint": "eslint -c ../../.eslintrc.js \"src/**/*.ts\"",
12+
"lint": "npx eslint -c ../../.eslintrc.js \"src/**/*.ts\" && node ./scripts/lint",
1313
"format": "prettier --config ../../prettier.config.js --ignore-path ../.prettierignore --write \"**/*.{ts,md,json}\"",
1414
"test": "yarn g:jest"
1515
},
1616
"main": "./dist-cjs/index.js",
1717
"module": "./dist-es/index.js",
1818
"types": "./dist-types/index.d.ts",
19+
"exports": {
20+
".": {
21+
"node": "./dist-cjs/index.js",
22+
"import": "./dist-es/index.js",
23+
"require": "./dist-cjs/index.js",
24+
"types": "./dist-types/index.d.ts"
25+
},
26+
"./package.json": {
27+
"node": "./package.json",
28+
"import": "./package.json",
29+
"require": "./package.json"
30+
}
31+
},
1932
"author": {
2033
"name": "AWS Smithy Team",
2134
"email": "",

packages/core/scripts/lint.js

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
const fs = require("fs");
2+
const path = require("path");
3+
4+
const root = path.join(__dirname, "..");
5+
6+
const pkgJson = require(path.join(root, "package.json"));
7+
const tsconfigs = {
8+
cjs: require(path.join(root, "tsconfig.cjs.json")),
9+
es: require(path.join(root, "tsconfig.es.json")),
10+
types: require(path.join(root, "tsconfig.types.json")),
11+
};
12+
const submodules = fs.readdirSync(path.join(root, "src", "submodules"));
13+
14+
const errors = [];
15+
16+
for (const submodule of submodules) {
17+
const submodulePath = path.join(root, "src", "submodules", submodule);
18+
if (fs.existsSync(submodulePath) && fs.lstatSync(submodulePath).isDirectory()) {
19+
// package.json metadata.
20+
if (!pkgJson.exports[`./${submodule}`]) {
21+
errors.push(`${submodule} submodule is missing exports statement in package.json`);
22+
pkgJson.exports[`./${submodule}`] = {
23+
node: `./dist-cjs/submodules/${submodule}/index.js`,
24+
import: `./dist-es/submodules/${submodule}/index.js`,
25+
require: `./dist-cjs/submodules/${submodule}/index.js`,
26+
types: `./dist-types/submodules/${submodule}/index.d.ts`,
27+
};
28+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(pkgJson, null, 2) + "\n");
29+
}
30+
if (!pkgJson.files.includes(`./${submodule}.js`)) {
31+
pkgJson.files.push(`./${submodule}.js`);
32+
errors.push(`package.json files array missing ${submodule}.js compatibility redirect file.`);
33+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(pkgJson, null, 2) + "\n");
34+
}
35+
// tsconfig metadata.
36+
for (const [kind, tsconfig] of Object.entries(tsconfigs)) {
37+
if (!tsconfig.compilerOptions.paths?.[`@smithy/core/${submodule}`]) {
38+
errors.push(`${submodule} submodule is missing paths entry in tsconfig.${kind}.json`);
39+
40+
tsconfig.compilerOptions.paths[`@smithy/core/${submodule}`] = [`./src/submodules/${submodule}/index.ts`];
41+
fs.writeFileSync(path.join(root, `tsconfig.${kind}.json`), JSON.stringify(tsconfig, null, 2) + "\n");
42+
}
43+
}
44+
// compatibility redirect file.
45+
const compatibilityRedirectFile = path.join(root, `${submodule}.js`);
46+
if (!fs.existsSync(compatibilityRedirectFile)) {
47+
errors.push(`${submodule} is missing compatibility redirect file in the package root folder.`);
48+
fs.writeFileSync(
49+
compatibilityRedirectFile,
50+
`
51+
/**
52+
* Do not edit:
53+
* This is a compatibility redirect for contexts that do not understand package.json exports field.
54+
*/
55+
module.exports = require("./dist-cjs/submodules/${submodule}/index.js");
56+
`
57+
);
58+
}
59+
}
60+
}
61+
62+
/**
63+
* Check for cross-submodule relative imports.
64+
*/
65+
66+
const walk = require("../../../scripts/utils/walk");
67+
68+
(async () => {
69+
for await (const item of walk(path.join(root, "src", "submodules"))) {
70+
// depth within the submodule where 1 is at the root of the submodule.
71+
const depth = item.split("core/src/submodules/")[1].split("/").length - 1;
72+
const sourceCode = fs.readFileSync(item, "utf-8");
73+
74+
const relativeImports = [];
75+
relativeImports.push(
76+
...new Set(
77+
[...(sourceCode.toString().match(/(from |import\()"(.*?)";/g) || [])]
78+
.map((_) => _.replace(/from "/g, "").replace(/";$/, ""))
79+
.filter((_) => _.startsWith("."))
80+
)
81+
);
82+
83+
for (const i of relativeImports) {
84+
const relativeImportDepth = i.split("..").length - 1;
85+
if (relativeImportDepth >= depth) {
86+
errors.push(
87+
`relative import ${i} in ${item
88+
.split("packages/")
89+
.pop()} crosses submodule boundaries. Use @scope/package/submodule import instead.`
90+
);
91+
}
92+
}
93+
}
94+
})().then(() => {
95+
if (errors.length) {
96+
throw new Error(errors.join("\n"));
97+
}
98+
});

packages/core/src/submodules/.gitkeep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
placeholder until submodules exist

packages/core/tsconfig.cjs.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"compilerOptions": {
33
"baseUrl": ".",
44
"outDir": "dist-cjs",
5-
"rootDir": "src"
5+
"rootDir": "src",
6+
"paths": {}
67
},
78
"extends": "../../tsconfig.cjs.json",
89
"include": ["src/"]

packages/core/tsconfig.es.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"baseUrl": ".",
44
"lib": ["dom"],
55
"outDir": "dist-es",
6-
"rootDir": "src"
6+
"rootDir": "src",
7+
"paths": {}
78
},
89
"extends": "../../tsconfig.es.json",
910
"include": ["src/"]

packages/core/tsconfig.types.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"compilerOptions": {
33
"baseUrl": ".",
44
"declarationDir": "dist-types",
5-
"rootDir": "src"
5+
"rootDir": "src",
6+
"paths": {}
67
},
78
"extends": "../../tsconfig.types.json",
89
"include": ["src/"]

scripts/cli-dispatcher/index.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require("fs");
4+
const path = require("path");
5+
const readline = require("readline");
6+
7+
const findFolders = require("./lib/findFolders");
8+
const findScripts = require("./lib/findScripts");
9+
const Package = require("./lib/Package");
10+
11+
/**
12+
* This script takes your command line arguments and infers the
13+
* package in which to execute them.
14+
*
15+
* It is supposed to save time moving among packages folders
16+
* for building and running other test commands.
17+
*/
18+
async function main() {
19+
console.log("CLI dispatcher");
20+
21+
const root = path.join(__dirname, "..", "..");
22+
const argv = process.argv;
23+
24+
const packages = fs.readdirSync(path.join(root, "packages"));
25+
const private = fs.readdirSync(path.join(root, "private"));
26+
27+
const allPackages = [
28+
...packages.map((p) => new Package(p, path.join(root, "packages", p))),
29+
...private.map((p) => new Package(p, path.join(root, "private", p))),
30+
];
31+
32+
const [node, dispatcher, ...rest] = argv;
33+
const flags = rest.filter((f) => f.startsWith("--"));
34+
const options = {
35+
dry: flags.includes("--dry"),
36+
help: flags.includes("--help") || rest.length === 0,
37+
confirm: flags.includes("--c"),
38+
};
39+
40+
if (options.help) {
41+
console.info(`
42+
Usage:
43+
b [package query words] - [command query words]
44+
b cor - b t
45+
46+
matches to:
47+
(cd packages/core && yarn build:types)
48+
49+
Query words are substrings that match against the package name and npm scripts.
50+
The substrings must appear in order for a match.
51+
Match priority goes to whole-word matching and initial matching.
52+
53+
Options:
54+
--dry
55+
dry run with no command execution.
56+
--help
57+
show this message.
58+
--c
59+
ask for confirmation before executing command.
60+
`);
61+
return 0;
62+
}
63+
64+
const nonFlags = rest.filter((_) => !_.startsWith("--"));
65+
const separatorIndex = rest.indexOf("-") !== -1 ? rest.indexOf("-") : rest.length;
66+
const query = nonFlags.slice(0, separatorIndex);
67+
const commands = nonFlags.slice(separatorIndex + 1);
68+
69+
const matchedPackages = findFolders(allPackages, ...query);
70+
71+
if (matchedPackages.length === 0) {
72+
console.error("No matching packages for query:", query);
73+
return 0;
74+
}
75+
76+
console.log("query:", ...query);
77+
console.log(
78+
"matches:",
79+
matchedPackages.map((_) => _.name)
80+
);
81+
82+
const [target] = matchedPackages;
83+
84+
const targetPkgJson = require(path.join(target.location, "package.json"));
85+
const matchedScripts = findScripts(Object.keys(targetPkgJson.scripts || {}), ...commands);
86+
const [script] = matchedScripts;
87+
88+
if (commands.length === 0) {
89+
console.info("No commands entered");
90+
return 0;
91+
}
92+
93+
if (matchedScripts.length === 0) {
94+
console.error("No matching scripts for command query:", commands);
95+
return 0;
96+
}
97+
98+
console.log("commands:", ...commands);
99+
console.log("matched commands:", matchedScripts);
100+
101+
const command = `yarn ${script} in ${target.location}`;
102+
103+
if (options.dry) {
104+
console.log("DRYRUN:", command);
105+
return 0;
106+
}
107+
108+
const execute = async () => {
109+
const { spawnProcess } = require("../utils/spawn-process");
110+
console.info("Running:", "yarn", script);
111+
console.info("Location:", target.location);
112+
await spawnProcess("yarn", [script], {
113+
cwd: target.location,
114+
stdio: "inherit",
115+
});
116+
return;
117+
};
118+
119+
if (options.confirm) {
120+
const rl = readline.createInterface({
121+
input: process.stdin,
122+
output: process.stdout,
123+
});
124+
125+
rl.question(`run script "${script}" in ${target.location} (y)/n?:`, async (confirm) => {
126+
if (confirm.toLowerCase().trim() === "y" || confirm === "") {
127+
await execute();
128+
}
129+
rl.close();
130+
});
131+
return 0;
132+
}
133+
134+
await execute();
135+
136+
return 0;
137+
}
138+
139+
main().catch(console.error);

scripts/cli-dispatcher/lib/Package.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = class Package {
2+
constructor(name, location) {
3+
this.name = name;
4+
this.location = location;
5+
}
6+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const matcher = require("./matcher");
2+
const matchSorter = require("./matchSorter");
3+
4+
/**
5+
* @param allPackages {Package[]} - list of all packages.
6+
* @param query {string} - query for the package list.
7+
* @returns the folders matching the args.
8+
*/
9+
module.exports = function findFolders(allPackages, ...query) {
10+
const folders = [];
11+
for (const pkg of allPackages) {
12+
const { name } = pkg;
13+
const isMatch = matcher(name, ...query);
14+
if (isMatch) {
15+
folders.push(pkg);
16+
}
17+
}
18+
return matchSorter(folders, ...query);
19+
};

0 commit comments

Comments
 (0)