@mandujs/core 0.54.17 → 0.54.19

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 (46) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +94 -25
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/agent/verify.ts +55 -24
  6. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  7. package/src/bundler/__tests__/build-runner.ts +130 -17
  8. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  9. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  10. package/src/bundler/build.test.ts +478 -9
  11. package/src/bundler/build.ts +424 -746
  12. package/src/bundler/client-boundary-transform.ts +977 -0
  13. package/src/bundler/dev.ts +39 -112
  14. package/src/bundler/fast-refresh-preamble.ts +47 -0
  15. package/src/bundler/index.ts +3 -2
  16. package/src/bundler/manifest-schema.ts +10 -0
  17. package/src/bundler/types.ts +20 -2
  18. package/src/client/__tests__/props-serialization.test.ts +37 -0
  19. package/src/client/hydrate.ts +2 -2
  20. package/src/client/index.ts +1 -1
  21. package/src/client/props-serialization.ts +233 -0
  22. package/src/client/runtime-entry.ts +567 -0
  23. package/src/client/runtime.ts +1 -1
  24. package/src/client/serialize.ts +50 -404
  25. package/src/diagnose/__tests__/checks.test.ts +132 -17
  26. package/src/diagnose/checks.ts +184 -3
  27. package/src/diagnose/run.ts +10 -8
  28. package/src/generator/templates.test.ts +48 -5
  29. package/src/generator/templates.ts +10 -1
  30. package/src/internal/client-boundary.ts +266 -0
  31. package/src/internal/index.ts +2 -1
  32. package/src/router/client-entry.test.ts +154 -29
  33. package/src/router/client-entry.ts +111 -313
  34. package/src/router/fs-routes.test.ts +443 -1
  35. package/src/router/fs-routes.ts +16 -3
  36. package/src/router/fs-scanner.ts +176 -57
  37. package/src/router/fs-types.ts +11 -2
  38. package/src/router/route-source-analyzer.ts +521 -0
  39. package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
  40. package/src/runtime/__tests__/page-render-response.test.ts +218 -0
  41. package/src/runtime/handlers.ts +50 -26
  42. package/src/runtime/page-render-response.ts +24 -1
  43. package/src/runtime/server.ts +14 -0
  44. package/src/runtime/ssr.ts +16 -5
  45. package/src/runtime/streaming-ssr.ts +119 -76
  46. package/src/spec/schema.ts +31 -5
@@ -1,6 +1,11 @@
1
- import { readFile } from "fs/promises";
2
- import path from "path";
3
- import type { RouteSpec } from "../spec/schema";
1
+ import { readFile } from "fs/promises";
2
+ import path from "path";
3
+ import type { RouteSpec } from "../spec/schema";
4
+ import {
5
+ formatClientBoundaryDiagnostics,
6
+ validateClientBoundaryServerOnlyImports,
7
+ } from "../bundler/client-boundary-transform";
8
+ import { analyzeRouteSource, type RouteSourceImportRecord } from "./route-source-analyzer";
4
9
 
5
10
  export interface ClientComponentImport {
6
11
  module: string;
@@ -30,13 +35,13 @@ export function normalizeRouteModulePath(value: string | undefined): string {
30
35
  return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
31
36
  }
32
37
 
33
- export function hasUseClientDirective(source: string): boolean {
34
- return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
35
- }
36
-
37
- export function hasUseServerDirective(source: string): boolean {
38
- return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
39
- }
38
+ export function hasUseClientDirective(source: string): boolean {
39
+ return analyzeRouteSource(source).directives.useClient;
40
+ }
41
+
42
+ export function hasUseServerDirective(source: string): boolean {
43
+ return analyzeRouteSource(source).directives.useServer;
44
+ }
40
45
 
41
46
  export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
42
47
  if (route.kind !== "page" || !clientModule) return false;
@@ -71,77 +76,65 @@ function toPublicClientComponentImport(entry: ComponentImportRecord): ClientComp
71
76
  }
72
77
 
73
78
  function findComponentImportRecords(source: string): ComponentImportRecord[] {
74
- const imports: ComponentImportRecord[] = [];
75
- const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
76
- const sideEffectPattern = /import\s+["']([^"']+)["']/g;
77
-
78
- for (const match of source.matchAll(importFromPattern)) {
79
- const clause = (match[1] ?? "").trim();
80
- const module = match[2] ?? "";
81
- const names: string[] = [];
82
- const specifiers: ComponentImportSpecifier[] = [];
83
- let hasDefault = false;
84
- let hasNamed = false;
85
- let hasNamespace = false;
86
-
87
- const namedMatch = clause.match(/\{([^}]*)\}/);
88
- if (namedMatch) {
89
- hasNamed = true;
90
- for (const rawName of namedMatch[1].split(",")) {
91
- const name = rawName.trim();
92
- if (!name) continue;
93
- const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
94
- const importedName = parts[0] ?? "";
95
- const localName = parts[1] ?? importedName;
96
- names.push(localName);
97
- specifiers.push({ importedName, localName });
98
- }
99
- }
79
+ return findComponentImportRecordsFromAnalysis(analyzeRouteSource(source).imports);
80
+ }
100
81
 
101
- if (/\*\s+as\s+/.test(clause)) {
102
- hasNamespace = true;
103
- const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
104
- if (namespaceMatch?.[1]) {
105
- names.push(namespaceMatch[1]);
106
- specifiers.push({ importedName: "*", localName: namespaceMatch[1] });
107
- }
108
- }
82
+ function findComponentImportRecordsFromAnalysis(imports: RouteSourceImportRecord[]): ComponentImportRecord[] {
83
+ return imports.flatMap(toComponentImportRecord);
84
+ }
109
85
 
110
- const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
111
- if (beforeNamed && !beforeNamed.startsWith("*")) {
112
- hasDefault = true;
113
- const localName = beforeNamed.split(",")[0].trim();
114
- names.push(localName);
115
- specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName });
116
- }
117
-
118
- const kind =
119
- (hasDefault && (hasNamed || hasNamespace))
120
- ? "mixed"
121
- : hasNamed
122
- ? "named"
123
- : hasNamespace
124
- ? "namespace"
125
- : "default";
126
-
127
- imports.push({ module, kind, names, specifiers });
86
+ function toComponentImportRecord(entry: RouteSourceImportRecord): ComponentImportRecord[] {
87
+ if (entry.isSideEffectOnly) {
88
+ return [{ module: entry.source, kind: "side-effect", names: [], specifiers: [] }];
128
89
  }
129
-
130
- for (const match of source.matchAll(sideEffectPattern)) {
131
- const module = match[1] ?? "";
132
- if (imports.some((entry) => entry.module === module)) continue;
133
- imports.push({ module, kind: "side-effect", names: [], specifiers: [] });
90
+ if (entry.isTypeOnly) return [];
91
+
92
+ const names: string[] = [];
93
+ const specifiers: ComponentImportSpecifier[] = [];
94
+ let hasDefault = false;
95
+ let hasNamed = false;
96
+ let hasNamespace = false;
97
+
98
+ if (entry.defaultName) {
99
+ hasDefault = true;
100
+ names.push(entry.defaultName);
101
+ specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName: entry.defaultName });
134
102
  }
135
103
 
136
- return imports;
137
- }
104
+ if (entry.namespaceName) {
105
+ hasNamespace = true;
106
+ names.push(entry.namespaceName);
107
+ specifiers.push({ importedName: "*", localName: entry.namespaceName });
108
+ }
109
+
110
+ for (const named of entry.named) {
111
+ if (named.isTypeOnly) continue;
112
+ hasNamed = true;
113
+ names.push(named.local);
114
+ specifiers.push({ importedName: named.imported, localName: named.local });
115
+ }
116
+
117
+ if (names.length === 0) return [];
118
+
119
+ const kind =
120
+ (hasDefault && (hasNamed || hasNamespace))
121
+ ? "mixed"
122
+ : hasNamed
123
+ ? "named"
124
+ : hasNamespace
125
+ ? "namespace"
126
+ : "default";
127
+
128
+ return [{ module: entry.source, kind, names, specifiers }];
129
+ }
138
130
 
139
131
  export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
140
132
  return findRouteLevelClientComponentImports(source)[0] ?? null;
141
133
  }
142
134
 
143
135
  export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
144
- const candidates = findComponentImportRecords(source).flatMap((entry) =>
136
+ const analysis = analyzeRouteSource(source);
137
+ const candidates = findComponentImportRecordsFromAnalysis(analysis.imports).flatMap((entry) =>
145
138
  isRouteLevelClientEntrySpecifier(entry.module)
146
139
  ? entry.specifiers
147
140
  .filter((specifier) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(specifier.localName))
@@ -152,10 +145,10 @@ export function findRouteLevelClientComponentImports(source: string): RouteLevel
152
145
  }))
153
146
  : []
154
147
  );
155
-
156
- if (candidates.length === 0) return [];
157
- return defaultExportRendersClientComponents(source, candidates);
158
- }
148
+
149
+ if (candidates.length === 0) return [];
150
+ return defaultExportRendersClientComponents(analysis.defaultExport.renderedJsxNames, candidates);
151
+ }
159
152
 
160
153
  function isRouteLevelClientEntrySpecifier(specifier: string): boolean {
161
154
  const normalized = specifier.replace(/\\/g, "/");
@@ -268,241 +261,29 @@ function clientSpecifierLooksBrowserOnly(specifier: string): boolean {
268
261
  return CLIENT_ENTRY_SPECIFIER_PATTERN.test(specifier.replace(/\\/g, "/"));
269
262
  }
270
263
 
271
- function defaultExportRendersClientComponents(
272
- source: string,
273
- candidates: RouteLevelClientComponentImport[],
274
- ): RouteLevelClientComponentImport[] {
275
- const functionBody = extractDefaultExportFunctionBody(source);
276
- if (functionBody !== null) {
277
- const returned = extractTopLevelReturnExpression(functionBody);
278
- return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
279
- }
280
-
281
- const arrowExpression = extractDefaultExportArrowExpression(source);
282
- if (arrowExpression !== null) {
283
- return jsxExpressionRendersClientComponents(arrowExpression, candidates);
284
- }
285
-
286
- const arrowBody = extractDefaultExportArrowFunctionBody(source);
287
- if (arrowBody !== null) {
288
- const returned = extractTopLevelReturnExpression(arrowBody);
289
- return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
290
- }
291
-
292
- return [];
293
- }
294
-
295
- function extractDefaultExportFunctionBody(source: string): string | null {
296
- const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
297
- if (!match) return null;
298
-
299
- const openBrace = match.index + match[0].lastIndexOf("{");
300
- const closeBrace = findMatchingBrace(source, openBrace);
301
- if (closeBrace === -1) return null;
302
- return source.slice(openBrace + 1, closeBrace);
303
- }
304
-
305
- function extractDefaultExportArrowExpression(source: string): string | null {
306
- const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
307
- if (!match) return null;
308
-
309
- const start = match.index + match[0].length;
310
- const rest = source.slice(start).trim();
311
- if (rest.startsWith("{")) return null;
312
-
313
- const semicolon = rest.indexOf(";");
314
- return semicolon === -1 ? rest : rest.slice(0, semicolon);
315
- }
316
-
317
- function extractDefaultExportArrowFunctionBody(source: string): string | null {
318
- const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/m.exec(source);
319
- if (!match) return null;
320
-
321
- const openBrace = match.index + match[0].lastIndexOf("{");
322
- const closeBrace = findMatchingBrace(source, openBrace);
323
- if (closeBrace === -1) return null;
324
- return source.slice(openBrace + 1, closeBrace);
325
- }
326
-
327
- function extractTopLevelReturnExpression(body: string): string | null {
328
- let quote: '"' | "'" | "`" | null = null;
329
- let lineComment = false;
330
- let blockComment = false;
331
- let braceDepth = 0;
332
- let parenDepth = 0;
333
- let bracketDepth = 0;
334
-
335
- for (let i = 0; i < body.length; i++) {
336
- const char = body[i];
337
- const next = body[i + 1];
338
- const prev = body[i - 1];
339
-
340
- if (lineComment) {
341
- if (char === "\n" || char === "\r") lineComment = false;
342
- continue;
343
- }
344
-
345
- if (blockComment) {
346
- if (char === "*" && next === "/") {
347
- blockComment = false;
348
- i++;
349
- }
350
- continue;
351
- }
352
-
353
- if (quote) {
354
- if (char === quote && prev !== "\\") quote = null;
355
- continue;
356
- }
357
-
358
- if (char === "/" && next === "/") {
359
- lineComment = true;
360
- i++;
361
- continue;
362
- }
363
- if (char === "/" && next === "*") {
364
- blockComment = true;
365
- i++;
366
- continue;
367
- }
368
- if (char === '"' || char === "'" || char === "`") {
369
- quote = char;
370
- continue;
371
- }
372
-
373
- if (braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && body.startsWith("return", i)) {
374
- const before = body[i - 1] ?? "";
375
- const after = body[i + "return".length] ?? "";
376
- if (!isIdentifierChar(before) && !isIdentifierChar(after)) {
377
- const expr = body.slice(i + "return".length).trim();
378
- return trimTrailingSemicolon(expr);
379
- }
380
- }
381
-
382
- if (char === "{") braceDepth++;
383
- if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
384
- if (char === "(") parenDepth++;
385
- if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
386
- if (char === "[") bracketDepth++;
387
- if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
388
- }
389
-
390
- return null;
391
- }
392
-
393
- function trimTrailingSemicolon(value: string): string {
394
- const trimmed = value.trim();
395
- return trimmed.endsWith(";") ? trimmed.slice(0, -1).trim() : trimmed;
396
- }
397
-
398
- function jsxExpressionRendersClientComponents(
399
- expression: string,
400
- candidates: RouteLevelClientComponentImport[],
401
- ): RouteLevelClientComponentImport[] {
402
- const expr = stripWrappingParentheses(expression.trim());
403
- const seen = new Set<string>();
404
- const rendered: RouteLevelClientComponentImport[] = [];
405
-
406
- for (const candidate of candidates) {
407
- const key = `${candidate.module}\0${candidate.localName}`;
408
- if (seen.has(key)) continue;
409
- if (!jsxExpressionContainsClientElement(expr, candidate.localName)) continue;
410
- seen.add(key);
411
- rendered.push(candidate);
412
- }
413
-
414
- return rendered;
415
- }
416
-
417
- function jsxExpressionContainsClientElement(expression: string, localName: string): boolean {
418
- const escaped = escapeRegExp(localName);
419
- return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
420
- }
421
-
422
- function stripWrappingParentheses(value: string): string {
423
- let current = value.trim();
424
- while (current.startsWith("(") && current.endsWith(")")) {
425
- const close = findMatchingParen(current, 0);
426
- if (close !== current.length - 1) break;
427
- current = current.slice(1, -1).trim();
428
- }
429
- return current;
430
- }
431
-
432
- function findMatchingBrace(source: string, openIndex: number): number {
433
- return findMatchingDelimiter(source, openIndex, "{", "}");
434
- }
435
-
436
- function findMatchingParen(source: string, openIndex: number): number {
437
- return findMatchingDelimiter(source, openIndex, "(", ")");
438
- }
439
-
440
- function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
441
- let depth = 0;
442
- let quote: '"' | "'" | "`" | null = null;
443
- let lineComment = false;
444
- let blockComment = false;
445
-
446
- for (let i = openIndex; i < source.length; i++) {
447
- const char = source[i];
448
- const next = source[i + 1];
449
- const prev = source[i - 1];
450
-
451
- if (lineComment) {
452
- if (char === "\n" || char === "\r") lineComment = false;
453
- continue;
454
- }
455
-
456
- if (blockComment) {
457
- if (char === "*" && next === "/") {
458
- blockComment = false;
459
- i++;
460
- }
461
- continue;
462
- }
463
-
464
- if (quote) {
465
- if (char === quote && prev !== "\\") quote = null;
466
- continue;
467
- }
468
-
469
- if (char === "/" && next === "/") {
470
- lineComment = true;
471
- i++;
472
- continue;
473
- }
474
- if (char === "/" && next === "*") {
475
- blockComment = true;
476
- i++;
477
- continue;
478
- }
479
- if (char === '"' || char === "'" || char === "`") {
480
- quote = char;
481
- continue;
482
- }
483
-
484
- if (char === open) depth++;
485
- if (char === close) {
486
- depth--;
487
- if (depth === 0) return i;
488
- }
489
- }
490
-
491
- return -1;
492
- }
493
-
494
- function escapeRegExp(value: string): string {
495
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
496
- }
497
-
498
- function isIdentifierChar(value: string): boolean {
499
- return /[A-Za-z0-9_$]/.test(value);
500
- }
264
+ function defaultExportRendersClientComponents(
265
+ renderedJsxNames: string[],
266
+ candidates: RouteLevelClientComponentImport[],
267
+ ): RouteLevelClientComponentImport[] {
268
+ const renderedNames = new Set(renderedJsxNames);
269
+ const seen = new Set<string>();
270
+ const rendered: RouteLevelClientComponentImport[] = [];
271
+
272
+ for (const candidate of candidates) {
273
+ const key = `${candidate.module}\0${candidate.localName}`;
274
+ if (seen.has(key)) continue;
275
+ if (!renderedNames.has(candidate.localName)) continue;
276
+ seen.add(key);
277
+ rendered.push(candidate);
278
+ }
279
+
280
+ return rendered;
281
+ }
501
282
 
502
- export async function validateClientModuleForBrowserBundle(
503
- route: RouteSpec,
504
- rootDir: string,
505
- ): Promise<string | null> {
283
+ export async function validateClientModuleForBrowserBundle(
284
+ route: RouteSpec,
285
+ rootDir: string,
286
+ ): Promise<string | null> {
506
287
  if (!route.clientModule) return null;
507
288
 
508
289
  const source = await readRouteModule(rootDir, route.clientModule);
@@ -517,17 +298,34 @@ export async function validateClientModuleForBrowserBundle(
517
298
  if (realClientEntry) {
518
299
  route.clientModule = realClientEntry.modulePath;
519
300
  route.clientExportName = realClientEntry.exportName;
520
- return null;
301
+ const realSource = await readRouteModule(rootDir, route.clientModule);
302
+ return realSource === null ? null : formatClientModuleBrowserDiagnostics(route, realSource);
521
303
  }
522
304
  return (
523
305
  `[${route.id}] Route component "${route.clientModule}" is configured as clientModule, ` +
524
306
  `but it is a server page (missing "use client"). Mandu will not bundle server pages into client islands. ` +
525
307
  `Remove the stale clientModule from .mandu/routes.manifest.json or use a *.partial.tsx / spec/slots/${route.id}.client.tsx client entry.`
526
308
  );
527
- }
528
-
529
- return null;
530
- }
309
+ }
310
+
311
+ return formatClientModuleBrowserDiagnostics(route, source);
312
+ }
313
+
314
+ function formatClientModuleBrowserDiagnostics(route: RouteSpec, source: string): string | null {
315
+ if (!route.clientModule) return null;
316
+ const diagnostics = validateClientBoundaryServerOnlyImports(
317
+ source,
318
+ {
319
+ id: `${route.id}--client-module`,
320
+ routeId: route.id,
321
+ module: route.clientModule,
322
+ exportName: route.clientExportName ?? "default",
323
+ },
324
+ route.clientModule,
325
+ );
326
+ if (diagnostics.length === 0) return null;
327
+ return formatClientBoundaryDiagnostics(diagnostics);
328
+ }
531
329
 
532
330
  async function routeComponentHasResolvableClientEntry(
533
331
  rootDir: string,