@akanjs/devkit 3.1.0-rc.22 → 3.1.0-rc.24
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/README.ko.md +1 -1
- package/README.md +1 -1
- package/akanConfig/akanConfig.test.ts +8 -4
- package/akanConfig/akanConfig.ts +2 -1
- package/executors.test.ts +1 -1
- package/executors.ts +1 -1
- package/frontendBuild/csrArtifactBuilder.test.ts +40 -0
- package/frontendBuild/csrArtifactBuilder.ts +2 -1
- package/package.json +3 -3
- package/transforms/sideEffectImportOrder.test.ts +183 -0
- package/transforms/sideEffectImportOrder.ts +109 -0
package/README.ko.md
CHANGED
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ defaults, the shares they derive from `AKAN_MEMORY_LIMIT`, and what a small cont
|
|
|
69
69
|
|
|
70
70
|
## Requirements
|
|
71
71
|
|
|
72
|
-
- [Bun](https://bun.sh) `>=1.4.
|
|
72
|
+
- [Bun](https://bun.sh) `>=1.4.4`
|
|
73
73
|
- TypeScript
|
|
74
74
|
- `react` is an optional peer, needed only by the features that render.
|
|
75
75
|
|
|
@@ -11,6 +11,10 @@ import type { LibConfigInput } from "./types";
|
|
|
11
11
|
const akanPackageJson = JSON.parse(
|
|
12
12
|
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
|
|
13
13
|
) as PackageJson;
|
|
14
|
+
const rootPackageJson = JSON.parse(
|
|
15
|
+
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../../package.json"), "utf8"),
|
|
16
|
+
) as { engines: { bun: string } };
|
|
17
|
+
const pinnedBunImage = `oven/bun:${rootPackageJson.engines.bun}-slim`;
|
|
14
18
|
|
|
15
19
|
const packageJson: PackageJson = {
|
|
16
20
|
name: "repo",
|
|
@@ -206,9 +210,9 @@ describe("AkanAppConfig", () => {
|
|
|
206
210
|
});
|
|
207
211
|
|
|
208
212
|
test("writes the Dockerfile instructions it wrote before its env came from getProductionEnv", () => {
|
|
209
|
-
//? 9adfb95c's output for this config
|
|
213
|
+
//? 9adfb95c's output for this config, less the blank lines its empty interpolations left and its floating tag.
|
|
210
214
|
const before = [
|
|
211
|
-
|
|
215
|
+
`FROM ${pinnedBunImage}`,
|
|
212
216
|
"RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*",
|
|
213
217
|
"RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime",
|
|
214
218
|
"ARG TARGETARCH",
|
|
@@ -266,11 +270,11 @@ describe("AkanAppConfig", () => {
|
|
|
266
270
|
expect(config.dockerfile).toBe(dockerfile);
|
|
267
271
|
});
|
|
268
272
|
|
|
269
|
-
test("resolves the image parts, defaulting the base image
|
|
273
|
+
test("resolves the image parts, defaulting the command and the base image to the Bun the repo pins", () => {
|
|
270
274
|
const config = new AkanAppConfig(app, [], packageJson, { docker: { preRuns: ["echo hi"] } }, baseDevEnv);
|
|
271
275
|
|
|
272
276
|
expect(config.docker).toEqual({
|
|
273
|
-
image:
|
|
277
|
+
image: pinnedBunImage,
|
|
274
278
|
preRuns: ["echo hi"],
|
|
275
279
|
postRuns: [],
|
|
276
280
|
command: ["bun", "main.js"],
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -47,7 +47,8 @@ const DEFAULT_OPTIMIZE_IMPORTS = [
|
|
|
47
47
|
"@material-ui/icons @tabler/icons-react mui-core react-icons/*",
|
|
48
48
|
].flatMap((line) => line.split(" "));
|
|
49
49
|
const WORKSPACE_BARREL_FACETS = ["ui", "webkit", "common", "client", "server"] as const;
|
|
50
|
-
|
|
50
|
+
// `1-slim` floats to whichever Bun is newest at build time, one the framework was never tested on.
|
|
51
|
+
const DEFAULT_DOCKER_IMAGE = "oven/bun:1.4.4-slim";
|
|
51
52
|
const SSR_RUNTIME_PACKAGES = ["react", "react-dom", "react-server-dom-webpack"] as const;
|
|
52
53
|
const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
|
|
53
54
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
package/executors.test.ts
CHANGED
|
@@ -1078,7 +1078,7 @@ describe("PkgExecutor package generation", () => {
|
|
|
1078
1078
|
expect(distPackageJson).toMatchObject({
|
|
1079
1079
|
name: "@sample/tool",
|
|
1080
1080
|
type: "module",
|
|
1081
|
-
engines: { bun: ">=1.4.
|
|
1081
|
+
engines: { bun: ">=1.4.4" },
|
|
1082
1082
|
dependencies: { lodash: "4.0.0" },
|
|
1083
1083
|
devDependencies: { typescript: "6.0.0" },
|
|
1084
1084
|
peerDependencies: { react: "19.0.0" },
|
package/executors.ts
CHANGED
|
@@ -1640,7 +1640,7 @@ export class PkgExecutor extends Executor {
|
|
|
1640
1640
|
default: "./index.ts",
|
|
1641
1641
|
},
|
|
1642
1642
|
},
|
|
1643
|
-
engines: { bun: ">=1.4.
|
|
1643
|
+
engines: { bun: ">=1.4.4" },
|
|
1644
1644
|
...dependencyMaps,
|
|
1645
1645
|
};
|
|
1646
1646
|
await Promise.all([this.dist.writeJson("package.json", distPkgJson), this.writeJson("package.json", distPkgJson)]);
|
|
@@ -61,4 +61,44 @@ console.info(await result.outputs[0].text());
|
|
|
61
61
|
expect(firstOutput).toBe(secondOutput);
|
|
62
62
|
expect(firstOutput).not.toContain(path.basename(first));
|
|
63
63
|
});
|
|
64
|
+
|
|
65
|
+
//? The shape of `@lexical/code-prism` under a `lazy()` editor, which threw in every view and CSR shell carrying it.
|
|
66
|
+
test("runs a lazily imported module's CommonJS side-effect import before the imports after it", async () => {
|
|
67
|
+
const root = await makeTempRoot();
|
|
68
|
+
const app = { getPublicEnv: () => ({ AKAN_PUBLIC_ENV: "main" }) };
|
|
69
|
+
await writeText(path.join(root, "node_modules/core/package.json"), '{ "name": "core", "main": "index.js" }\n');
|
|
70
|
+
await writeText(
|
|
71
|
+
path.join(root, "node_modules/core/index.js"),
|
|
72
|
+
"var Core = { langs: [] };\nglobalThis.Core = Core;\nmodule.exports = Core;\n",
|
|
73
|
+
);
|
|
74
|
+
await writeText(path.join(root, "node_modules/core/components/a.js"), 'Core.langs.push("a");\n');
|
|
75
|
+
await writeText(
|
|
76
|
+
path.join(root, "node_modules/highlight/package.json"),
|
|
77
|
+
'{ "name": "highlight", "type": "module", "main": "index.js" }\n',
|
|
78
|
+
);
|
|
79
|
+
await writeText(
|
|
80
|
+
path.join(root, "node_modules/highlight/index.js"),
|
|
81
|
+
'import "core";\nimport "core/components/a.js";\nexport const langs = () => globalThis.Core.langs;\n',
|
|
82
|
+
);
|
|
83
|
+
await writeText(
|
|
84
|
+
path.join(root, "entry.ts"),
|
|
85
|
+
'import("highlight").then(({ langs }) => console.info(langs().join()));\n',
|
|
86
|
+
);
|
|
87
|
+
const result = await Bun.build({
|
|
88
|
+
...CsrArtifactBuilder.bundleOptions(app as never, "build"),
|
|
89
|
+
entrypoints: [path.join(root, "entry.ts")],
|
|
90
|
+
});
|
|
91
|
+
expect(result.success).toBe(true);
|
|
92
|
+
await Bun.write(path.join(root, "out.js"), (await result.outputs[0]?.text()) ?? "");
|
|
93
|
+
|
|
94
|
+
const proc = Bun.spawn([process.execPath, "out.js"], { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
95
|
+
const [stdout, stderr] = await Promise.all([
|
|
96
|
+
new Response(proc.stdout).text(),
|
|
97
|
+
new Response(proc.stderr).text(),
|
|
98
|
+
proc.exited,
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(stderr).toBe("");
|
|
102
|
+
expect(stdout.trim()).toBe("a");
|
|
103
|
+
});
|
|
64
104
|
});
|
|
@@ -4,6 +4,7 @@ import type { BaseBuildArtifact } from "akanjs/server";
|
|
|
4
4
|
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
6
|
import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
|
|
7
|
+
import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
|
|
7
8
|
import { bundleDefine } from "./bundleDefine";
|
|
8
9
|
import { CsrEntryFiles } from "./csrEntryFiles";
|
|
9
10
|
import { type McpAppView, McpAppViewBuilder } from "./mcpAppViewBuilder";
|
|
@@ -113,7 +114,7 @@ export class CsrArtifactBuilder {
|
|
|
113
114
|
define: bundleDefine(app, command, "csr"),
|
|
114
115
|
optimizeImports,
|
|
115
116
|
// A raw `.css` in the route graph is Tailwind source; the base artifact's compiled sheet is the only stylesheet.
|
|
116
|
-
plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin()],
|
|
117
|
+
plugins: [PagesBundleBuilder.createCssStubPlugin(), createBrowserDirnamePlugin(), SideEffectImportOrder.plugin()],
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.24",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"directory": "pkgs/@akanjs/devkit"
|
|
15
15
|
},
|
|
16
16
|
"engines": {
|
|
17
|
-
"bun": ">=1.4.
|
|
17
|
+
"bun": ">=1.4.4"
|
|
18
18
|
},
|
|
19
19
|
"exports": {
|
|
20
20
|
".": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
51
|
+
"akanjs": "3.1.0-rc.24",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText } from "../testHelpers";
|
|
4
|
+
import { SideEffectImportOrder } from "./sideEffectImportOrder";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-devkit-side-effect-order-");
|
|
7
|
+
|
|
8
|
+
const writePackage = async (root: string, name: string, files: Record<string, string>, manifest = {}) => {
|
|
9
|
+
await writeText(
|
|
10
|
+
path.join(root, "node_modules", name, "package.json"),
|
|
11
|
+
`${JSON.stringify({ name, main: "index.js", ...manifest })}\n`,
|
|
12
|
+
);
|
|
13
|
+
for (const [file, source] of Object.entries(files))
|
|
14
|
+
await writeText(path.join(root, "node_modules", name, file), source);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
//? Prism's shape: a CommonJS core that sets a global, then language files that read it, imported only by `import()`.
|
|
18
|
+
const writeHighlighter = async (root: string) => {
|
|
19
|
+
await writePackage(root, "core", {
|
|
20
|
+
"index.js": "var Core = { langs: [] };\nglobalThis.Core = Core;\nmodule.exports = Core;\n",
|
|
21
|
+
"components/a.js": 'Core.langs.push("a");\n',
|
|
22
|
+
});
|
|
23
|
+
await writePackage(
|
|
24
|
+
root,
|
|
25
|
+
"highlight",
|
|
26
|
+
{
|
|
27
|
+
"index.js": 'import "core";\nimport "core/components/a.js";\nexport const langs = () => globalThis.Core.langs;\n',
|
|
28
|
+
},
|
|
29
|
+
{ type: "module" },
|
|
30
|
+
);
|
|
31
|
+
const entry = path.join(root, "entry.ts");
|
|
32
|
+
await writeText(entry, 'import("highlight").then(({ langs }) => console.info(JSON.stringify(langs())));\n');
|
|
33
|
+
return entry;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const bundle = async (entry: string, plugins = [SideEffectImportOrder.plugin()]) => {
|
|
37
|
+
const result = await Bun.build({ entrypoints: [entry], target: "browser", splitting: false, minify: true, plugins });
|
|
38
|
+
expect(result.success).toBe(true);
|
|
39
|
+
return (await result.outputs[0]?.text()) ?? "";
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const run = async (root: string, code: string) => {
|
|
43
|
+
const outFile = path.join(root, "out.js");
|
|
44
|
+
await Bun.write(outFile, code);
|
|
45
|
+
const proc = Bun.spawn([process.execPath, outFile], { stdout: "pipe", stderr: "pipe" });
|
|
46
|
+
const [stdout, stderr] = await Promise.all([
|
|
47
|
+
new Response(proc.stdout).text(),
|
|
48
|
+
new Response(proc.stderr).text(),
|
|
49
|
+
proc.exited,
|
|
50
|
+
]);
|
|
51
|
+
return { stdout: stdout.trim(), stderr };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
describe("SideEffectImportOrder", () => {
|
|
55
|
+
test("runs a CommonJS side-effect import before the imports after it in a module only import() reaches", async () => {
|
|
56
|
+
const root = await makeTempRoot();
|
|
57
|
+
|
|
58
|
+
const { stdout, stderr } = await run(root, await bundle(await writeHighlighter(root)));
|
|
59
|
+
|
|
60
|
+
expect(stderr).toBe("");
|
|
61
|
+
expect(stdout).toBe('["a"]');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
//? A shim would keep what the bundler drops: a bare import of a module its package declares free of side effects.
|
|
65
|
+
test("bundles bare imports of ES modules, and of modules a package calls pure, as it would without the plugin", async () => {
|
|
66
|
+
const root = await makeTempRoot();
|
|
67
|
+
await writePackage(
|
|
68
|
+
root,
|
|
69
|
+
"chunks",
|
|
70
|
+
{
|
|
71
|
+
"index.js": 'import "./a.js";\nexport const a = () => globalThis.a;\n',
|
|
72
|
+
"a.js": 'globalThis.a = "chunk";\nexport {};\n',
|
|
73
|
+
},
|
|
74
|
+
{ type: "module" },
|
|
75
|
+
);
|
|
76
|
+
await writePackage(
|
|
77
|
+
root,
|
|
78
|
+
"pure",
|
|
79
|
+
{
|
|
80
|
+
"index.js": 'import "./esm.js";\nimport "./cjs.cjs";\nexport const used = () => "used";\n',
|
|
81
|
+
"esm.js": 'globalThis.esm = "PURE_ESM";\nexport {};\n',
|
|
82
|
+
"cjs.cjs": 'globalThis.cjs = "PURE_CJS";\nmodule.exports = {};\n',
|
|
83
|
+
},
|
|
84
|
+
{ type: "module", sideEffects: false },
|
|
85
|
+
);
|
|
86
|
+
const entry = path.join(root, "entry.ts");
|
|
87
|
+
await writeText(
|
|
88
|
+
entry,
|
|
89
|
+
'import("chunks").then(({ a }) => console.info(a()));\nimport("pure").then(({ used }) => console.info(used()));\n',
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const [plugged, plain] = [await bundle(entry), await bundle(entry, [])];
|
|
93
|
+
|
|
94
|
+
expect(plugged).toBe(plain);
|
|
95
|
+
expect(plugged).not.toContain("PURE_");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("resolves a shimmed import as the bundler resolves the original, browser field included", async () => {
|
|
99
|
+
const root = await makeTempRoot();
|
|
100
|
+
await writePackage(
|
|
101
|
+
root,
|
|
102
|
+
"pick",
|
|
103
|
+
{
|
|
104
|
+
"index.js": 'globalThis.picked = "node";\nmodule.exports = {};\n',
|
|
105
|
+
"browser.js": 'globalThis.picked = "browser";\nmodule.exports = {};\n',
|
|
106
|
+
},
|
|
107
|
+
{ browser: "browser.js" },
|
|
108
|
+
);
|
|
109
|
+
await writePackage(
|
|
110
|
+
root,
|
|
111
|
+
"picker",
|
|
112
|
+
{ "index.js": 'import "pick";\nexport const picked = () => globalThis.picked;\n' },
|
|
113
|
+
{
|
|
114
|
+
type: "module",
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
const entry = path.join(root, "entry.ts");
|
|
118
|
+
await writeText(entry, 'import("picker").then(({ picked }) => console.info(picked()));\n');
|
|
119
|
+
|
|
120
|
+
const { stdout } = await run(root, await bundle(entry));
|
|
121
|
+
|
|
122
|
+
expect(stdout).toBe("browser");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
//? How the CSR shell and every view build: their entry HTML lives only in the build's `files`.
|
|
126
|
+
test("leaves an entry point a build hands in files to the bundler", async () => {
|
|
127
|
+
const root = await makeTempRoot();
|
|
128
|
+
await writeHighlighter(root);
|
|
129
|
+
const html = path.join(root, "generated/index.html");
|
|
130
|
+
const result = await Bun.build({
|
|
131
|
+
entrypoints: [html],
|
|
132
|
+
files: {
|
|
133
|
+
[html]: '<!doctype html><html><body><script type="module" src="./boot.ts"></script></body></html>\n',
|
|
134
|
+
[path.join(root, "generated/boot.ts")]:
|
|
135
|
+
'import("highlight").then(({ langs }) => console.info(langs().join()));\n',
|
|
136
|
+
},
|
|
137
|
+
target: "browser",
|
|
138
|
+
splitting: false,
|
|
139
|
+
outdir: path.join(root, "out"),
|
|
140
|
+
plugins: [SideEffectImportOrder.plugin()],
|
|
141
|
+
});
|
|
142
|
+
expect(result.success).toBe(true);
|
|
143
|
+
const script = result.outputs.find((output) => output.path.endsWith(".js"));
|
|
144
|
+
|
|
145
|
+
const { stdout, stderr } = await run(root, (await script?.text()) ?? "");
|
|
146
|
+
|
|
147
|
+
expect(stderr).toBe("");
|
|
148
|
+
expect(stdout).toBe("a");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
//? An OTA release is keyed on the CSR bundle's hash, and a shim is named after its importer's absolute path.
|
|
152
|
+
test("bundles a shimmed import identically from two checkout paths", async () => {
|
|
153
|
+
const [first, second] = [await makeTempRoot(), await makeTempRoot()];
|
|
154
|
+
|
|
155
|
+
const [firstOutput, secondOutput] = [
|
|
156
|
+
await bundle(await writeHighlighter(first)),
|
|
157
|
+
await bundle(await writeHighlighter(second)),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
expect(firstOutput).toBe(secondOutput);
|
|
161
|
+
expect(firstOutput).not.toContain(path.basename(first));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("points only a module's bare imports at their shims", async () => {
|
|
165
|
+
const root = await makeTempRoot();
|
|
166
|
+
await writePackage(root, "mod", {}, { type: "module" });
|
|
167
|
+
const source = [
|
|
168
|
+
'import "a";',
|
|
169
|
+
'import"b";import { c } from "c";',
|
|
170
|
+
'export * from "d";',
|
|
171
|
+
'import "e" with { type: "json" };',
|
|
172
|
+
'const text = `import "f";`;',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
const rewritten = await SideEffectImportOrder.rewrite(source.join("\n"), path.join(root, "node_modules/mod/x.js"));
|
|
176
|
+
|
|
177
|
+
expect(rewritten).toBe(
|
|
178
|
+
['import "akan-side-effect:a";', 'import"akan-side-effect:b";import { c } from "c";', ...source.slice(2)].join(
|
|
179
|
+
"\n",
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { BunPlugin } from "bun";
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
|
|
6
|
+
//* Without splitting, Bun (1.4.4 included) wraps a module that only an `import()` reaches, and the wrapper runs its ESM
|
|
7
|
+
//* imports before its CommonJS ones whatever their order: `@lexical/code-prism` imports `prismjs`, then language files
|
|
8
|
+
//* that read the `Prism` global it sets, so a single-file bundle threw. A vendor module's bare import that may run late
|
|
9
|
+
//* is pointed at a shim importing the same specifier instead: an ESM import, so it keeps its place.
|
|
10
|
+
export class SideEffectImportOrder {
|
|
11
|
+
static readonly #prefix = "akan-side-effect:";
|
|
12
|
+
static readonly #vendorModule = /[\\/]node_modules[\\/].+\.m?js$/;
|
|
13
|
+
static readonly #shim = /\.akan-side-effect$/;
|
|
14
|
+
|
|
15
|
+
static plugin(): BunPlugin {
|
|
16
|
+
return {
|
|
17
|
+
name: "akan-side-effect-order",
|
|
18
|
+
setup(build) {
|
|
19
|
+
const specifiers = new Map<string, string>();
|
|
20
|
+
//? Listed after the `__dirname` plugin: Bun runs one onLoad per module, so a module that one rewrites stays as is.
|
|
21
|
+
build.onLoad({ filter: SideEffectImportOrder.#vendorModule }, async ({ path: modulePath }) => {
|
|
22
|
+
const source = await Bun.file(modulePath).text();
|
|
23
|
+
const rewritten = await SideEffectImportOrder.rewrite(source, modulePath);
|
|
24
|
+
return rewritten === source ? undefined : { contents: rewritten, loader: "js" };
|
|
25
|
+
});
|
|
26
|
+
//? The prefix alone: an onResolve that matches an import loses its package's `sideEffects`, even unanswered.
|
|
27
|
+
build.onResolve({ filter: /^akan-side-effect:/ }, ({ path: prefixed, importer }) => {
|
|
28
|
+
const specifier = prefixed.slice(SideEffectImportOrder.#prefix.length);
|
|
29
|
+
//? Beside its importer, so the bundler resolves the shim's import as it would have the importer's.
|
|
30
|
+
const shimPath = `${importer}.${Bun.hash(specifier).toString(36)}.akan-side-effect`;
|
|
31
|
+
specifiers.set(shimPath, specifier);
|
|
32
|
+
return { path: shimPath };
|
|
33
|
+
});
|
|
34
|
+
build.onLoad({ filter: SideEffectImportOrder.#shim }, ({ path: shimPath }) => ({
|
|
35
|
+
contents: `import ${JSON.stringify(specifiers.get(shimPath))};\n`,
|
|
36
|
+
loader: "js",
|
|
37
|
+
}));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static async rewrite(source: string, filePath: string): Promise<string> {
|
|
43
|
+
if (!/\bimport\s*["']/.test(source)) return source;
|
|
44
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS);
|
|
45
|
+
const literals = sourceFile.statements.flatMap((statement) =>
|
|
46
|
+
ts.isImportDeclaration(statement) &&
|
|
47
|
+
!statement.importClause &&
|
|
48
|
+
!statement.attributes &&
|
|
49
|
+
ts.isStringLiteral(statement.moduleSpecifier)
|
|
50
|
+
? [statement.moduleSpecifier]
|
|
51
|
+
: [],
|
|
52
|
+
);
|
|
53
|
+
const late = await Promise.all(literals.map((literal) => SideEffectImportOrder.#runsLate(literal.text, filePath)));
|
|
54
|
+
return literals
|
|
55
|
+
.filter((_, idx) => late[idx])
|
|
56
|
+
.reverse()
|
|
57
|
+
.reduce(
|
|
58
|
+
(rewritten, literal) =>
|
|
59
|
+
`${rewritten.slice(0, literal.getStart(sourceFile))}${JSON.stringify(`${SideEffectImportOrder.#prefix}${literal.text}`)}${rewritten.slice(literal.end)}`,
|
|
60
|
+
source,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//? Judged off the specifier, never resolved: a `Bun.resolveSync` primes the bundler's resolution, browser field lost.
|
|
65
|
+
static async #runsLate(specifier: string, importer: string) {
|
|
66
|
+
const from = path.dirname(importer);
|
|
67
|
+
const target = specifier.startsWith(".") ? path.resolve(from, specifier) : null;
|
|
68
|
+
//? An ESM import already keeps its place among the others.
|
|
69
|
+
if (target && (await SideEffectImportOrder.#isEsModule(target))) return false;
|
|
70
|
+
const sideEffects = await SideEffectImportOrder.#sideEffectsOf(
|
|
71
|
+
target ? path.dirname(target) : SideEffectImportOrder.#packageDirOf(specifier, from),
|
|
72
|
+
);
|
|
73
|
+
//? The bundler drops a bare import of a module its package declares pure, which a shim would keep.
|
|
74
|
+
return sideEffects === undefined || sideEffects === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static async #isEsModule(filePath: string) {
|
|
78
|
+
const file = Bun.file(filePath);
|
|
79
|
+
if (!/\.m?js$/.test(filePath) || !(await file.exists())) return false;
|
|
80
|
+
const sourceFile = ts.createSourceFile(
|
|
81
|
+
filePath,
|
|
82
|
+
await file.text(),
|
|
83
|
+
ts.ScriptTarget.Latest,
|
|
84
|
+
false,
|
|
85
|
+
ts.ScriptKind.JS,
|
|
86
|
+
);
|
|
87
|
+
return ts.isExternalModule(sourceFile);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static #packageDirOf(specifier: string, from: string) {
|
|
91
|
+
const name = specifier
|
|
92
|
+
.split("/")
|
|
93
|
+
.slice(0, specifier.startsWith("@") ? 2 : 1)
|
|
94
|
+
.join("/");
|
|
95
|
+
for (let dir = from; ; dir = path.dirname(dir)) {
|
|
96
|
+
const packageDir = path.join(dir, "node_modules", name);
|
|
97
|
+
if (existsSync(packageDir)) return packageDir;
|
|
98
|
+
if (path.dirname(dir) === dir) return from;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async #sideEffectsOf(fromDir: string): Promise<unknown> {
|
|
103
|
+
for (let dir = fromDir; ; dir = path.dirname(dir)) {
|
|
104
|
+
const manifest = Bun.file(path.join(dir, "package.json"));
|
|
105
|
+
if (await manifest.exists()) return ((await manifest.json()) as { sideEffects?: unknown }).sideEffects;
|
|
106
|
+
if (path.dirname(dir) === dir) return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|