@anaemia/bundler 0.3.7 → 0.5.0

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 (73) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1 -1
  3. package/dist/aliases.js +1 -1
  4. package/dist/analyzer/ast-utils.d.ts +5 -0
  5. package/dist/analyzer/ast-utils.d.ts.map +1 -0
  6. package/dist/analyzer/ast-utils.js +16 -0
  7. package/dist/analyzer/ast-walker.d.ts +15 -0
  8. package/dist/analyzer/ast-walker.d.ts.map +1 -0
  9. package/dist/analyzer/ast-walker.js +43 -0
  10. package/dist/analyzer/checks/env-access.d.ts +3 -0
  11. package/dist/analyzer/checks/env-access.d.ts.map +1 -0
  12. package/dist/analyzer/checks/env-access.js +63 -0
  13. package/dist/analyzer/checks/route-metadata.d.ts +12 -0
  14. package/dist/analyzer/checks/route-metadata.d.ts.map +1 -0
  15. package/dist/analyzer/checks/route-metadata.js +74 -0
  16. package/dist/analyzer/checks/server-functions.d.ts +3 -0
  17. package/dist/analyzer/checks/server-functions.d.ts.map +1 -0
  18. package/dist/analyzer/checks/server-functions.js +75 -0
  19. package/dist/analyzer/index.d.ts +7 -0
  20. package/dist/analyzer/index.d.ts.map +1 -0
  21. package/dist/analyzer/index.js +49 -0
  22. package/dist/analyzer/parser.d.ts +4 -0
  23. package/dist/analyzer/parser.d.ts.map +1 -0
  24. package/dist/analyzer/parser.js +96 -0
  25. package/dist/analyzer/types.d.ts +48 -0
  26. package/dist/analyzer/types.d.ts.map +1 -0
  27. package/dist/analyzer/types.js +1 -0
  28. package/dist/env-loader.d.ts +2 -0
  29. package/dist/env-loader.d.ts.map +1 -0
  30. package/dist/env-loader.js +10 -0
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +89 -15
  34. package/dist/optimization.d.ts.map +1 -1
  35. package/dist/optimization.js +17 -4
  36. package/dist/plugins/babel-transform-server.d.ts.map +1 -1
  37. package/dist/plugins/babel-transform-server.js +1 -4
  38. package/dist/router/generate-entry.d.ts.map +1 -1
  39. package/dist/router/generate-entry.js +3 -3
  40. package/dist/router/generate-server-routes.d.ts.map +1 -1
  41. package/dist/router/generate-server-routes.js +15 -5
  42. package/dist/router/manifest.d.ts +3 -2
  43. package/dist/router/manifest.d.ts.map +1 -1
  44. package/dist/router/manifest.js +20 -20
  45. package/dist/router/scan.d.ts.map +1 -1
  46. package/dist/router/scan.js +5 -6
  47. package/dist/rules.d.ts +16 -1
  48. package/dist/rules.d.ts.map +1 -1
  49. package/dist/rules.js +37 -5
  50. package/package.json +11 -3
  51. package/src/aliases.ts +2 -2
  52. package/src/analyzer/ast-utils.ts +22 -0
  53. package/src/analyzer/ast-walker.ts +63 -0
  54. package/src/analyzer/checks/env-access.ts +77 -0
  55. package/src/analyzer/checks/route-metadata.ts +91 -0
  56. package/src/analyzer/checks/server-functions.ts +85 -0
  57. package/src/analyzer/index.ts +70 -0
  58. package/src/analyzer/parser.ts +103 -0
  59. package/src/analyzer/types.ts +55 -0
  60. package/src/env-loader.ts +13 -0
  61. package/src/index.ts +119 -19
  62. package/src/optimization.ts +18 -5
  63. package/src/plugins/babel-transform-server.ts +1 -4
  64. package/src/plugins/rspack-manifest-hydration.ts +3 -3
  65. package/src/router/generate-entry.ts +15 -6
  66. package/src/router/generate-server-routes.ts +16 -5
  67. package/src/router/manifest.ts +24 -38
  68. package/src/router/scan.ts +9 -10
  69. package/src/rules.ts +48 -8
  70. package/test/analyzer.test.mjs +308 -0
  71. package/test/rspack-config.test.mjs +5 -2
  72. package/test/server-functions.test.mjs +25 -22
  73. package/tsconfig.json +1 -1
@@ -0,0 +1,308 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ import { analyzeApp, walkAst } from "../dist/analyzer/index.js";
8
+
9
+ function createTmpProject() {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "anaemia-analyzer-test-"));
11
+ fs.mkdirSync(path.join(dir, "src/routes/users/[id]"), { recursive: true });
12
+ fs.mkdirSync(path.join(dir, "src/features/auth/components"), { recursive: true });
13
+ fs.mkdirSync(path.join(dir, "src/shared/utils"), { recursive: true });
14
+
15
+ fs.writeFileSync(
16
+ path.join(dir, "anaemia.config.ts"),
17
+ `
18
+ import { defineConfig } from "@anaemia/core";
19
+ export default defineConfig({ port: 3005 });
20
+ `,
21
+ );
22
+
23
+ fs.writeFileSync(path.join(dir, "src/root.tsx"), `export default function Root(props) { return props.children; }`);
24
+
25
+ // route with PUBLIC_ env - valid
26
+ fs.writeFileSync(
27
+ path.join(dir, "src/routes/users/[id]/index.tsx"),
28
+ `export default function UserPage() { return <h1>{import.meta.env.PUBLIC_API_URL}</h1>; }`,
29
+ );
30
+
31
+ // route with non-PUBLIC_ env - should warn
32
+ fs.writeFileSync(
33
+ path.join(dir, "src/routes/index.tsx"),
34
+ `export default function Home() { return <div>{import.meta.env.SECRET_KEY}</div>; }`,
35
+ );
36
+
37
+ // route with process.env - should warn
38
+ fs.writeFileSync(
39
+ path.join(dir, "src/routes/about.tsx"),
40
+ `export default function About() { return <div>{process.env.API_KEY}</div>; }`,
41
+ );
42
+
43
+ // server route - env access should not warn
44
+ fs.writeFileSync(
45
+ path.join(dir, "src/routes/users/[id]/_route.ts"),
46
+ `export const GET = () => new Response(import.meta.env.SECRET_KEY);`,
47
+ );
48
+
49
+ // .server file - env access should not warn
50
+ fs.writeFileSync(
51
+ path.join(dir, "src/features/auth/components/auth.server.ts"),
52
+ `export const getToken = () => import.meta.env.SECRET_TOKEN;`,
53
+ );
54
+
55
+ // feature component with non-PUBLIC_ env - should warn
56
+ fs.writeFileSync(
57
+ path.join(dir, "src/features/auth/components/Login.tsx"),
58
+ `export function Login() { return <form action={import.meta.env.AUTH_URL} />; }`,
59
+ );
60
+
61
+ // server function definition
62
+ fs.writeFileSync(
63
+ path.join(dir, "src/features/auth/components/actions.server.ts"),
64
+ `
65
+ import { runOnServer } from "@anaemia/core";
66
+ export const getUser = runOnServer(async (id) => ({ id }), "getUser");
67
+ export const unusedFn = runOnServer(async () => {}, "unusedFn");
68
+ `,
69
+ );
70
+
71
+ // only imports getUser, not unusedFn
72
+ fs.writeFileSync(
73
+ path.join(dir, "src/routes/users/[id]/index.tsx"),
74
+ `
75
+ import { getUser } from "../../features/auth/components/actions.server";
76
+ export default function UserPage() { return <h1>{import.meta.env.PUBLIC_API_URL}</h1>; }
77
+ `,
78
+ );
79
+
80
+ return dir;
81
+ }
82
+
83
+ // file classification
84
+
85
+ test("analyzeApp classifies file kinds correctly", async () => {
86
+ const dir = createTmpProject();
87
+ try {
88
+ const result = await analyzeApp(dir, { mode: "test" });
89
+ const kinds = new Map(result.files.map((f) => [f.relativePath, f.kind]));
90
+
91
+ assert.equal(kinds.get("anaemia.config.ts"), "config");
92
+ assert.equal(kinds.get("src/root.tsx"), "root");
93
+ assert.equal(kinds.get("src/routes/users/[id]/index.tsx"), "route");
94
+ assert.equal(kinds.get("src/routes/users/[id]/_route.ts"), "server-route");
95
+ assert.equal(result.build.mode, "test");
96
+ } finally {
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ // defineConfig
102
+
103
+ test("analyzeApp handles defineConfig in anaemia.config.ts without errors", async () => {
104
+ const dir = createTmpProject();
105
+ try {
106
+ const result = await analyzeApp(dir, { mode: "test" });
107
+ const configFile = result.files.find((f) => f.relativePath === "anaemia.config.ts");
108
+
109
+ assert.ok(configFile, "config file should be present");
110
+ assert.ok(configFile.program !== null, "config file should parse without error");
111
+
112
+ const configErrors = configFile.diagnostics.filter((d) => d.severity === "error");
113
+ assert.equal(configErrors.length, 0, "config file should have no parse errors");
114
+ } finally {
115
+ fs.rmSync(dir, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ // env access
120
+
121
+ test("env check: PUBLIC_ prefix passes without warning", async () => {
122
+ const dir = createTmpProject();
123
+ try {
124
+ const result = await analyzeApp(dir, { mode: "test" });
125
+ const warnings = result.diagnostics.filter(
126
+ (d) => d.code === "ENV_NOT_PUBLIC" && d.file?.includes("users/[id]/index.tsx"),
127
+ );
128
+ assert.equal(warnings.length, 0);
129
+ } finally {
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ test("env check: non-PUBLIC_ prefix in route warns", async () => {
135
+ const dir = createTmpProject();
136
+ try {
137
+ const result = await analyzeApp(dir, { mode: "test" });
138
+ const warnings = result.diagnostics.filter(
139
+ (d) => d.code === "ENV_NOT_PUBLIC" && d.filePath?.includes("routes/index.tsx"),
140
+ );
141
+ assert.equal(warnings.length, 1);
142
+ assert.ok(warnings[0].message.includes("SECRET_KEY"));
143
+ } finally {
144
+ fs.rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test("env check: non-PUBLIC_ prefix in feature component warns", async () => {
149
+ const dir = createTmpProject();
150
+ try {
151
+ const result = await analyzeApp(dir, { mode: "test" });
152
+ const warnings = result.diagnostics.filter((d) => d.code === "ENV_NOT_PUBLIC" && d.filePath?.includes("Login.tsx"));
153
+ assert.equal(warnings.length, 1);
154
+ assert.ok(warnings[0].message.includes("AUTH_URL"));
155
+ } finally {
156
+ fs.rmSync(dir, { recursive: true, force: true });
157
+ }
158
+ });
159
+
160
+ test("env check: server route does not warn for non-PUBLIC_ env", async () => {
161
+ const dir = createTmpProject();
162
+ try {
163
+ const result = await analyzeApp(dir, { mode: "test" });
164
+ const warnings = result.diagnostics.filter((d) => d.code === "ENV_NOT_PUBLIC" && d.file?.includes("_route.ts"));
165
+ assert.equal(warnings.length, 0);
166
+ } finally {
167
+ fs.rmSync(dir, { recursive: true, force: true });
168
+ }
169
+ });
170
+
171
+ test("env check: .server.ts file does not warn for non-PUBLIC_ env", async () => {
172
+ const dir = createTmpProject();
173
+ try {
174
+ const result = await analyzeApp(dir, { mode: "test" });
175
+ const warnings = result.diagnostics.filter(
176
+ (d) => d.code === "ENV_NOT_PUBLIC" && d.file?.includes("auth.server.ts"),
177
+ );
178
+ assert.equal(warnings.length, 0);
179
+ } finally {
180
+ fs.rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ test("env check: process.env usage warns", async () => {
185
+ const dir = createTmpProject();
186
+ try {
187
+ const result = await analyzeApp(dir, { mode: "test" });
188
+ const warnings = result.diagnostics.filter(
189
+ (d) => d.code === "PROCESS_ENV_ACCESS" && d.filePath?.includes("about.tsx"),
190
+ );
191
+ assert.equal(warnings.length, 1);
192
+ } finally {
193
+ fs.rmSync(dir, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test("env check: ALWAYS_SAFE keys do not warn", async () => {
198
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "anaemia-analyzer-test-"));
199
+ try {
200
+ fs.mkdirSync(path.join(dir, "src/routes"), { recursive: true });
201
+ fs.writeFileSync(
202
+ path.join(dir, "src/routes/index.tsx"),
203
+ `
204
+ export default function Page() {
205
+ return <div>{import.meta.env.NODE_ENV}{import.meta.env.MODE}{import.meta.env.DEV}{import.meta.env.PROD}</div>;
206
+ }
207
+ `,
208
+ );
209
+ const result = await analyzeApp(dir, { mode: "test" });
210
+ const warnings = result.diagnostics.filter((d) => d.code === "ENV_NOT_PUBLIC");
211
+ assert.equal(warnings.length, 0);
212
+ } finally {
213
+ fs.rmSync(dir, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ // unused server functions
218
+
219
+ test("unused server functions: imported function does not warn", async () => {
220
+ const dir = createTmpProject();
221
+ try {
222
+ const result = await analyzeApp(dir, { mode: "test" });
223
+ const warnings = result.diagnostics.filter(
224
+ (d) => d.code === "UNUSED_SERVER_FUNCTION" && d.message?.includes("getUser"),
225
+ );
226
+ assert.equal(warnings.length, 0);
227
+ } finally {
228
+ fs.rmSync(dir, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ test("unused server functions: unimported function warns", async () => {
233
+ const dir = createTmpProject();
234
+ try {
235
+ const result = await analyzeApp(dir, { mode: "test" });
236
+ const warnings = result.diagnostics.filter(
237
+ (d) => d.code === "UNUSED_SERVER_FUNCTION" && d.message?.includes("unusedFn"),
238
+ );
239
+ assert.equal(warnings.length, 1);
240
+ } finally {
241
+ fs.rmSync(dir, { recursive: true, force: true });
242
+ }
243
+ });
244
+
245
+ // route metadata
246
+
247
+ test("route metadata: static route detected correctly", async () => {
248
+ const dir = createTmpProject();
249
+ try {
250
+ const result = await analyzeApp(dir, { mode: "test" });
251
+ const aboutMeta = result.routeMetadata?.find((m) => m.filePath.includes("about.tsx"));
252
+ assert.ok(aboutMeta);
253
+ assert.equal(aboutMeta.isStatic, true);
254
+ assert.equal(aboutMeta.hasLoader, false);
255
+ assert.equal(aboutMeta.hasGuard, false);
256
+ } finally {
257
+ fs.rmSync(dir, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ test("route metadata: route with server function import is not static", async () => {
262
+ const dir = createTmpProject();
263
+ try {
264
+ const result = await analyzeApp(dir, { mode: "test" });
265
+ const userMeta = result.routeMetadata?.find((m) => m.filePath.includes("users/[id]/index.tsx"));
266
+ assert.ok(userMeta);
267
+ assert.equal(userMeta.isStatic, false);
268
+ assert.equal(userMeta.hasServerFunctions, true);
269
+ } finally {
270
+ fs.rmSync(dir, { recursive: true, force: true });
271
+ }
272
+ });
273
+
274
+ test("route metadata: params extracted from file path", async () => {
275
+ const dir = createTmpProject();
276
+ try {
277
+ const result = await analyzeApp(dir, { mode: "test" });
278
+ const userMeta = result.routeMetadata?.find((m) => m.filePath.includes("users/[id]"));
279
+ assert.ok(userMeta);
280
+ assert.ok(userMeta.params.includes("id"));
281
+ } finally {
282
+ fs.rmSync(dir, { recursive: true, force: true });
283
+ }
284
+ });
285
+
286
+ // walkAst
287
+
288
+ test("walkAst visits all expected node types", async () => {
289
+ const dir = createTmpProject();
290
+ try {
291
+ const result = await analyzeApp(dir, { include: ["src/routes/**/*.tsx"] });
292
+ const routeFile = result.files.find((f) => f.relativePath === "src/routes/users/[id]/index.tsx");
293
+
294
+ assert.ok(routeFile);
295
+ const seen = new Set();
296
+ walkAst(routeFile.program, {
297
+ enter(node) {
298
+ seen.add(node.type);
299
+ },
300
+ });
301
+
302
+ assert.ok(seen.has("Program"));
303
+ assert.ok(seen.has("ImportDeclaration"));
304
+ assert.ok(seen.has("ExportDefaultDeclaration"));
305
+ } finally {
306
+ fs.rmSync(dir, { recursive: true, force: true });
307
+ }
308
+ });
@@ -9,7 +9,10 @@ async function createTmpProject(isTs = true) {
9
9
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "anaemia-bundler-test-"));
10
10
 
11
11
  fs.mkdirSync(path.join(dir, "src/routes"), { recursive: true });
12
- fs.writeFileSync(path.join(dir, "src/routes/index.tsx"), `export default function Index() { return <div>hello</div>; }`);
12
+ fs.writeFileSync(
13
+ path.join(dir, "src/routes/index.tsx"),
14
+ `export default function Index() { return <div>hello</div>; }`,
15
+ );
13
16
  fs.writeFileSync(path.join(dir, "index.html"), `<html><body><div anaemia-entry></div></body></html>`);
14
17
 
15
18
  if (isTs) {
@@ -106,4 +109,4 @@ test("server config aliases point to dist not src", async () => {
106
109
  } finally {
107
110
  fs.rmSync(dir, { recursive: true, force: true });
108
111
  }
109
- });
112
+ });
@@ -15,12 +15,14 @@ const source = `
15
15
  `;
16
16
 
17
17
  function transform(plugin) {
18
- return transformSync(source, {
19
- filename,
20
- plugins: [plugin],
21
- configFile: false,
22
- babelrc: false,
23
- })?.code ?? "";
18
+ return (
19
+ transformSync(source, {
20
+ filename,
21
+ plugins: [plugin],
22
+ configFile: false,
23
+ babelrc: false,
24
+ })?.code ?? ""
25
+ );
24
26
  }
25
27
 
26
28
  test("client and server transforms generate the same server function id", () => {
@@ -42,36 +44,37 @@ test("client transform forwards call arguments to the RPC wrapper", () => {
42
44
  });
43
45
 
44
46
  test("client transform preserves explicit server function ids", () => {
45
- const code = transformSync(
46
- `
47
+ const code =
48
+ transformSync(
49
+ `
47
50
  import { runOnServer } from "@anaemia/core";
48
51
  export const ping = runOnServer(async () => "pong", "custom-id");
49
52
  `,
50
- {
51
- filename,
52
- plugins: [clientServerFnTransform],
53
- configFile: false,
54
- babelrc: false,
55
- }
56
- )?.code ?? "";
53
+ {
54
+ filename,
55
+ plugins: [clientServerFnTransform],
56
+ configFile: false,
57
+ babelrc: false,
58
+ },
59
+ )?.code ?? "";
57
60
 
58
61
  assert.match(code, /\$\$executeClientRpc\("custom-id"\)/);
59
62
  });
60
63
 
61
64
  test("should guarantee server-side logic never leaks to client assets", async () => {
62
65
  const clientAssetDir = path.resolve(process.cwd(), "dist/client/assets");
63
-
66
+
64
67
  if (!fs.existsSync(clientAssetDir)) return;
65
68
 
66
- const files = fs.readdirSync(clientAssetDir).filter(f => f.endsWith(".js"));
69
+ const files = fs.readdirSync(clientAssetDir).filter((f) => f.endsWith(".js"));
67
70
 
68
71
  for (const file of files) {
69
72
  const content = fs.readFileSync(path.join(clientAssetDir, file), "utf-8");
70
-
73
+
71
74
  assert.equal(
72
- content.includes("SELECT * FROM users"),
73
- false,
74
- `CRITICAL SECURITY LEAK: server logic found inside client asset: ${file}`
75
+ content.includes("SELECT * FROM users"),
76
+ false,
77
+ `CRITICAL SECURITY LEAK: server logic found inside client asset: ${file}`,
75
78
  );
76
79
  }
77
- });
80
+ });
package/tsconfig.json CHANGED
@@ -10,4 +10,4 @@
10
10
  "paths": {}
11
11
  },
12
12
  "include": ["src/**/*"]
13
- }
13
+ }