@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 CHANGED
@@ -70,7 +70,7 @@ await runner.build();
70
70
 
71
71
  ## 요구사항
72
72
 
73
- - [Bun](https://bun.sh) `>=1.4.0`
73
+ - [Bun](https://bun.sh) `>=1.4.4`
74
74
  - TypeScript
75
75
  - `react`는 optional peer이며, 렌더링하는 기능에서만 필요합니다.
76
76
 
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.0`
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; only the blank lines its empty interpolations left are gone since.
213
+ //? 9adfb95c's output for this config, less the blank lines its empty interpolations left and its floating tag.
210
214
  const before = [
211
- "FROM oven/bun:1-slim",
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 and the command", () => {
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: "oven/bun:1-slim",
277
+ image: pinnedBunImage,
274
278
  preRuns: ["echo hi"],
275
279
  postRuns: [],
276
280
  command: ["bun", "main.js"],
@@ -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
- const DEFAULT_DOCKER_IMAGE = "oven/bun:1-slim";
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.0" },
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.0" },
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.22",
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.0"
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.22",
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
+ }