@mandujs/core 0.54.16 → 0.54.18

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 (37) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +54 -16
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  6. package/src/bundler/__tests__/build-runner.ts +130 -17
  7. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  8. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  9. package/src/bundler/build.test.ts +440 -8
  10. package/src/bundler/build.ts +455 -132
  11. package/src/bundler/client-boundary-transform.ts +977 -0
  12. package/src/bundler/dev.ts +39 -112
  13. package/src/bundler/fast-refresh-preamble.ts +47 -0
  14. package/src/bundler/index.ts +3 -2
  15. package/src/bundler/manifest-schema.ts +10 -0
  16. package/src/bundler/types.ts +20 -2
  17. package/src/diagnose/__tests__/checks.test.ts +117 -17
  18. package/src/diagnose/checks.ts +184 -3
  19. package/src/diagnose/run.ts +10 -8
  20. package/src/generator/templates.test.ts +48 -5
  21. package/src/generator/templates.ts +10 -1
  22. package/src/internal/client-boundary.ts +266 -0
  23. package/src/internal/index.ts +2 -1
  24. package/src/router/client-entry.test.ts +43 -6
  25. package/src/router/client-entry.ts +33 -12
  26. package/src/router/fs-routes.test.ts +388 -1
  27. package/src/router/fs-routes.ts +16 -3
  28. package/src/router/fs-scanner.ts +166 -18
  29. package/src/router/fs-types.ts +4 -1
  30. package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
  31. package/src/runtime/__tests__/page-render-response.test.ts +212 -0
  32. package/src/runtime/handlers.ts +50 -26
  33. package/src/runtime/page-render-response.ts +43 -3
  34. package/src/runtime/server.ts +42 -5
  35. package/src/runtime/ssr.ts +16 -5
  36. package/src/runtime/streaming-ssr.ts +119 -76
  37. package/src/spec/schema.ts +31 -5
@@ -0,0 +1,977 @@
1
+ import * as ts from "typescript";
2
+
3
+ import type { HydrationPriority } from "../spec/schema";
4
+
5
+ const DEFAULT_BOUNDARY_IMPORT = "@mandujs/core/internal/client-boundary";
6
+ const BOUNDARY_COMPONENT = "__ManduClientBoundary";
7
+ const CLIENT_MODULE_PATTERN = /\.(?:client|island)(?:\.[cm]?[jt]sx?)?$/;
8
+
9
+ export type ClientBoundaryHydrateMode = HydrationPriority | "load" | "manual" | `media(${string})`;
10
+
11
+ export interface ClientBoundaryTransformOptions {
12
+ routeId: string;
13
+ fileName?: string;
14
+ hydrate?: ClientBoundaryHydrateMode;
15
+ boundaryImport?: string;
16
+ ordinalOffset?: number;
17
+ boundaryReplay?: readonly ClientBoundaryReplayRecord[];
18
+ }
19
+
20
+ export interface ClientBoundaryReplayRecord {
21
+ id?: string;
22
+ ordinal: number;
23
+ }
24
+
25
+ export interface ClientBoundaryRecord {
26
+ id: string;
27
+ routeId: string;
28
+ module: string;
29
+ importSpecifier: string;
30
+ exportName: string;
31
+ localName: string;
32
+ hydrate: ClientBoundaryHydrateMode;
33
+ ordinal: number;
34
+ propsSource: "inline";
35
+ propsKeys: string[];
36
+ hasSpreadProps: boolean;
37
+ source: {
38
+ file: string;
39
+ line: number;
40
+ column: number;
41
+ };
42
+ }
43
+
44
+ export interface ClientBoundaryDiagnostic {
45
+ code:
46
+ | "MANDU_BOUNDARY_UNSUPPORTED_CHILDREN"
47
+ | "MANDU_BOUNDARY_UNSUPPORTED_FUNCTION_PROP"
48
+ | "MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE"
49
+ | "MANDU_BOUNDARY_UNSUPPORTED_REF"
50
+ | "MANDU_BOUNDARY_INVALID_HOST_CONTEXT"
51
+ | "MANDU_BOUNDARY_SERVER_ONLY_IMPORT"
52
+ | "MANDU_BOUNDARY_UNRESOLVED_EXPORT";
53
+ severity: "error";
54
+ message: string;
55
+ suggestion: string;
56
+ routeId: string;
57
+ boundaryId?: string;
58
+ module?: string;
59
+ exportName?: string;
60
+ source: {
61
+ file: string;
62
+ line: number;
63
+ column: number;
64
+ };
65
+ }
66
+
67
+ export interface ClientBoundaryTransformResult {
68
+ code: string;
69
+ transformed: boolean;
70
+ boundaries: ClientBoundaryRecord[];
71
+ diagnostics: ClientBoundaryDiagnostic[];
72
+ }
73
+
74
+ export type ClientBoundaryExportValidationStatus = "found" | "missing" | "unknown";
75
+
76
+ const SERVER_ONLY_MODULE_SPECIFIERS = new Set([
77
+ "async_hooks",
78
+ "assert",
79
+ "buffer",
80
+ "child_process",
81
+ "cluster",
82
+ "console",
83
+ "constants",
84
+ "crypto",
85
+ "dgram",
86
+ "diagnostics_channel",
87
+ "dns",
88
+ "domain",
89
+ "events",
90
+ "fs",
91
+ "http",
92
+ "http2",
93
+ "https",
94
+ "inspector",
95
+ "module",
96
+ "net",
97
+ "os",
98
+ "path",
99
+ "perf_hooks",
100
+ "process",
101
+ "punycode",
102
+ "querystring",
103
+ "readline",
104
+ "repl",
105
+ "stream",
106
+ "string_decoder",
107
+ "timers",
108
+ "tls",
109
+ "tty",
110
+ "url",
111
+ "util",
112
+ "v8",
113
+ "vm",
114
+ "wasi",
115
+ "worker_threads",
116
+ "zlib",
117
+ ]);
118
+
119
+ const INVALID_BOUNDARY_HOST_CONTEXTS = new Set([
120
+ "table",
121
+ "thead",
122
+ "tbody",
123
+ "tfoot",
124
+ "tr",
125
+ "colgroup",
126
+ "select",
127
+ "optgroup",
128
+ "option",
129
+ "ul",
130
+ "ol",
131
+ "dl",
132
+ "p",
133
+ ]);
134
+
135
+ interface ClientImportBinding {
136
+ module: string;
137
+ exportName: string;
138
+ localName: string;
139
+ }
140
+
141
+ interface NamespaceImportBinding {
142
+ module: string;
143
+ namespace: string;
144
+ }
145
+
146
+ interface CollectedClientImports {
147
+ importsByLocalName: Map<string, ClientImportBinding>;
148
+ namespaceImports: Map<string, NamespaceImportBinding>;
149
+ removableImports: Set<ts.ImportDeclaration>;
150
+ hasBoundaryImport: boolean;
151
+ }
152
+
153
+ export function transformClientBoundaries(
154
+ source: string,
155
+ options: ClientBoundaryTransformOptions,
156
+ ): ClientBoundaryTransformResult {
157
+ const fileName = options.fileName ?? "route.tsx";
158
+ const hydrate = options.hydrate ?? "visible";
159
+ const boundaryImport = options.boundaryImport ?? DEFAULT_BOUNDARY_IMPORT;
160
+ const ordinalOffset = options.ordinalOffset ?? 0;
161
+ const sourceFile = ts.createSourceFile(
162
+ fileName,
163
+ source,
164
+ ts.ScriptTarget.Latest,
165
+ true,
166
+ ts.ScriptKind.TSX,
167
+ );
168
+ const collected = collectClientImports(sourceFile, boundaryImport);
169
+
170
+ if (collected.importsByLocalName.size === 0 && collected.namespaceImports.size === 0) {
171
+ return { code: source, transformed: false, boundaries: [], diagnostics: [] };
172
+ }
173
+
174
+ const boundaries: ClientBoundaryRecord[] = [];
175
+ const diagnostics: ClientBoundaryDiagnostic[] = [];
176
+
177
+ const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
178
+ const visitor: ts.Visitor = (node) => {
179
+ if (ts.isImportDeclaration(node) && collected.removableImports.has(node)) {
180
+ return undefined;
181
+ }
182
+
183
+ if (ts.isJsxSelfClosingElement(node)) {
184
+ const binding = resolveClientBinding(node.tagName, collected);
185
+ if (binding) {
186
+ pushInvalidHostContextDiagnostic({
187
+ element: node,
188
+ binding,
189
+ routeId: options.routeId,
190
+ sourceFile,
191
+ fileName,
192
+ diagnostics,
193
+ });
194
+ return createBoundaryElement({
195
+ element: node,
196
+ attributes: node.attributes,
197
+ binding,
198
+ routeId: options.routeId,
199
+ hydrate,
200
+ ordinalOffset,
201
+ boundaryReplay: options.boundaryReplay,
202
+ fileName,
203
+ sourceFile,
204
+ boundaries,
205
+ diagnostics,
206
+ });
207
+ }
208
+ }
209
+
210
+ if (ts.isJsxElement(node)) {
211
+ const binding = resolveClientBinding(node.openingElement.tagName, collected);
212
+ if (binding) {
213
+ pushInvalidHostContextDiagnostic({
214
+ element: node.openingElement,
215
+ binding,
216
+ routeId: options.routeId,
217
+ sourceFile,
218
+ fileName,
219
+ diagnostics,
220
+ });
221
+ const meaningfulChildren = node.children.filter(isMeaningfulJsxChild);
222
+ if (meaningfulChildren.length > 0) {
223
+ diagnostics.push(createDiagnostic(
224
+ "MANDU_BOUNDARY_UNSUPPORTED_CHILDREN",
225
+ "Client boundary transform currently supports self-closing client components only. Move children into serializable props or use an explicit island API.",
226
+ "Remove the children from this client component and pass plain serializable data as props, or keep this case on the explicit island API until server slots are supported.",
227
+ options.routeId,
228
+ undefined,
229
+ binding.module,
230
+ binding.exportName,
231
+ sourceFile,
232
+ fileName,
233
+ node,
234
+ ));
235
+ }
236
+ return createBoundaryElement({
237
+ element: node.openingElement,
238
+ attributes: node.openingElement.attributes,
239
+ binding,
240
+ routeId: options.routeId,
241
+ hydrate,
242
+ ordinalOffset,
243
+ boundaryReplay: options.boundaryReplay,
244
+ fileName,
245
+ sourceFile,
246
+ boundaries,
247
+ diagnostics,
248
+ });
249
+ }
250
+ }
251
+
252
+ return ts.visitEachChild(node, visitor, context);
253
+ };
254
+
255
+ return (node) => ts.visitNode(node, visitor) as ts.SourceFile;
256
+ };
257
+
258
+ const result = ts.transform(sourceFile, [transformer]);
259
+ const transformedSource = result.transformed[0] as ts.SourceFile;
260
+ const withBoundaryImport =
261
+ boundaries.length > 0 && !collected.hasBoundaryImport
262
+ ? addBoundaryImport(transformedSource, boundaryImport)
263
+ : transformedSource;
264
+ const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
265
+ const code = printer.printFile(withBoundaryImport);
266
+ result.dispose();
267
+
268
+ return {
269
+ code,
270
+ transformed: boundaries.length > 0,
271
+ boundaries,
272
+ diagnostics,
273
+ };
274
+ }
275
+
276
+ function pushInvalidHostContextDiagnostic(args: {
277
+ element: ts.Node;
278
+ binding: ClientImportBinding;
279
+ routeId: string;
280
+ sourceFile: ts.SourceFile;
281
+ fileName: string;
282
+ diagnostics: ClientBoundaryDiagnostic[];
283
+ }): void {
284
+ const invalidContext = findInvalidBoundaryHostContext(args.element);
285
+ if (!invalidContext) return;
286
+
287
+ args.diagnostics.push(createDiagnostic(
288
+ "MANDU_BOUNDARY_INVALID_HOST_CONTEXT",
289
+ `Client boundary transform cannot emit its placeholder inside <${invalidContext.tagName}> because the SSR marker uses sibling <div> and <script> nodes.`,
290
+ `Move this client component outside <${invalidContext.tagName}>, wrap the valid HTML section in a server component, or use an explicit island API until context-safe markers are supported.`,
291
+ args.routeId,
292
+ undefined,
293
+ args.binding.module,
294
+ args.binding.exportName,
295
+ args.sourceFile,
296
+ args.fileName,
297
+ invalidContext.node,
298
+ ));
299
+ }
300
+
301
+ function findInvalidBoundaryHostContext(element: ts.Node): { tagName: string; node: ts.Node } | null {
302
+ let current: ts.Node | undefined = element.parent;
303
+ while (current) {
304
+ if (ts.isJsxElement(current)) {
305
+ const tagName = getIntrinsicJsxTagName(current.openingElement.tagName);
306
+ if (tagName && INVALID_BOUNDARY_HOST_CONTEXTS.has(tagName)) {
307
+ return { tagName, node: current.openingElement };
308
+ }
309
+ }
310
+ current = current.parent;
311
+ }
312
+ return null;
313
+ }
314
+
315
+ function getIntrinsicJsxTagName(tagName: ts.JsxTagNameExpression): string | null {
316
+ if (!ts.isIdentifier(tagName)) return null;
317
+ const text = tagName.text;
318
+ return text.length > 0 && text[0] === text[0].toLowerCase()
319
+ ? text.toLowerCase()
320
+ : null;
321
+ }
322
+
323
+ function collectClientImports(sourceFile: ts.SourceFile, boundaryImport: string): CollectedClientImports {
324
+ const importsByLocalName = new Map<string, ClientImportBinding>();
325
+ const namespaceImports = new Map<string, NamespaceImportBinding>();
326
+ const removableImports = new Set<ts.ImportDeclaration>();
327
+ let hasBoundaryImport = false;
328
+
329
+ for (const statement of sourceFile.statements) {
330
+ if (!ts.isImportDeclaration(statement)) continue;
331
+ const moduleSpecifier = getStringModuleSpecifier(statement);
332
+ if (!moduleSpecifier) continue;
333
+ if (moduleSpecifier === boundaryImport) {
334
+ hasBoundaryImport = true;
335
+ continue;
336
+ }
337
+ if (!isClientModuleSpecifier(moduleSpecifier)) continue;
338
+
339
+ const importClause = statement.importClause;
340
+ if (!importClause || importClause.isTypeOnly) continue;
341
+
342
+ let hasRuntimeBinding = false;
343
+ if (importClause.name) {
344
+ importsByLocalName.set(importClause.name.text, {
345
+ module: moduleSpecifier,
346
+ exportName: "default",
347
+ localName: importClause.name.text,
348
+ });
349
+ hasRuntimeBinding = true;
350
+ }
351
+
352
+ const namedBindings = importClause.namedBindings;
353
+ if (namedBindings && ts.isNamedImports(namedBindings)) {
354
+ for (const element of namedBindings.elements) {
355
+ if (element.isTypeOnly) continue;
356
+ const importedName = element.propertyName?.text ?? element.name.text;
357
+ importsByLocalName.set(element.name.text, {
358
+ module: moduleSpecifier,
359
+ exportName: importedName,
360
+ localName: element.name.text,
361
+ });
362
+ hasRuntimeBinding = true;
363
+ }
364
+ }
365
+
366
+ if (namedBindings && ts.isNamespaceImport(namedBindings)) {
367
+ namespaceImports.set(namedBindings.name.text, {
368
+ module: moduleSpecifier,
369
+ namespace: namedBindings.name.text,
370
+ });
371
+ hasRuntimeBinding = true;
372
+ }
373
+
374
+ if (hasRuntimeBinding) {
375
+ removableImports.add(statement);
376
+ }
377
+ }
378
+
379
+ return {
380
+ importsByLocalName,
381
+ namespaceImports,
382
+ removableImports,
383
+ hasBoundaryImport,
384
+ };
385
+ }
386
+
387
+ function resolveClientBinding(
388
+ tagName: ts.JsxTagNameExpression,
389
+ collected: CollectedClientImports,
390
+ ): ClientImportBinding | null {
391
+ if (ts.isIdentifier(tagName)) {
392
+ return collected.importsByLocalName.get(tagName.text) ?? null;
393
+ }
394
+
395
+ if (
396
+ ts.isPropertyAccessExpression(tagName) &&
397
+ ts.isIdentifier(tagName.expression)
398
+ ) {
399
+ const namespace = collected.namespaceImports.get(tagName.expression.text);
400
+ if (!namespace) return null;
401
+ const localName = `${namespace.namespace}.${tagName.name.text}`;
402
+ return {
403
+ module: namespace.module,
404
+ exportName: tagName.name.text,
405
+ localName,
406
+ };
407
+ }
408
+
409
+ return null;
410
+ }
411
+
412
+ function createBoundaryElement(args: {
413
+ element: ts.Node;
414
+ attributes: ts.JsxAttributes;
415
+ binding: ClientImportBinding;
416
+ routeId: string;
417
+ hydrate: ClientBoundaryHydrateMode;
418
+ ordinalOffset: number;
419
+ boundaryReplay?: readonly ClientBoundaryReplayRecord[];
420
+ fileName: string;
421
+ sourceFile: ts.SourceFile;
422
+ boundaries: ClientBoundaryRecord[];
423
+ diagnostics: ClientBoundaryDiagnostic[];
424
+ }): ts.JsxSelfClosingElement {
425
+ const localOrdinal = args.boundaries.length;
426
+ const replay = args.boundaryReplay?.[localOrdinal];
427
+ const ordinal = replay?.ordinal ?? args.ordinalOffset + localOrdinal;
428
+ const id = replay?.id ?? `${args.routeId}--${ordinal}`;
429
+ const props = createPropsObject(
430
+ args.attributes,
431
+ args.sourceFile,
432
+ args.fileName,
433
+ args.element,
434
+ args.routeId,
435
+ id,
436
+ args.binding,
437
+ args.diagnostics,
438
+ );
439
+ const position = args.sourceFile.getLineAndCharacterOfPosition(args.element.getStart(args.sourceFile));
440
+
441
+ args.boundaries.push({
442
+ id,
443
+ routeId: args.routeId,
444
+ module: args.binding.module,
445
+ importSpecifier: args.binding.module,
446
+ exportName: args.binding.exportName,
447
+ localName: args.binding.localName,
448
+ hydrate: args.hydrate,
449
+ ordinal,
450
+ propsSource: "inline",
451
+ propsKeys: collectStaticPropKeys(args.attributes),
452
+ hasSpreadProps: args.attributes.properties.some(ts.isJsxSpreadAttribute),
453
+ source: {
454
+ file: args.fileName,
455
+ line: position.line + 1,
456
+ column: position.character + 1,
457
+ },
458
+ });
459
+
460
+ return ts.factory.createJsxSelfClosingElement(
461
+ ts.factory.createIdentifier(BOUNDARY_COMPONENT),
462
+ undefined,
463
+ ts.factory.createJsxAttributes([
464
+ createStringJsxAttribute("routeId", args.routeId),
465
+ createStringJsxAttribute("boundaryId", id),
466
+ createStringJsxAttribute("module", args.binding.module),
467
+ createStringJsxAttribute("exportName", args.binding.exportName),
468
+ createStringJsxAttribute("hydrate", args.hydrate),
469
+ ts.factory.createJsxAttribute(
470
+ ts.factory.createIdentifier("props"),
471
+ ts.factory.createJsxExpression(undefined, props),
472
+ ),
473
+ ]),
474
+ );
475
+ }
476
+
477
+ function createPropsObject(
478
+ attributes: ts.JsxAttributes,
479
+ sourceFile: ts.SourceFile,
480
+ fileName: string,
481
+ element: ts.Node,
482
+ routeId: string,
483
+ boundaryId: string,
484
+ binding: ClientImportBinding,
485
+ diagnostics: ClientBoundaryDiagnostic[],
486
+ ): ts.ObjectLiteralExpression {
487
+ const props: ts.ObjectLiteralElementLike[] = [];
488
+
489
+ for (const prop of attributes.properties) {
490
+ if (ts.isJsxSpreadAttribute(prop)) {
491
+ props.push(ts.factory.createSpreadAssignment(prop.expression));
492
+ continue;
493
+ }
494
+
495
+ const name = getJsxAttributeName(prop.name, sourceFile);
496
+ if (name === "key") continue;
497
+ if (name === "ref") {
498
+ diagnostics.push(createDiagnostic(
499
+ "MANDU_BOUNDARY_UNSUPPORTED_REF",
500
+ "Client boundary transform cannot serialize React refs across the server/client boundary.",
501
+ "Remove the ref from the server-rendered client boundary. If the client component needs a ref, create it inside the client component.",
502
+ routeId,
503
+ boundaryId,
504
+ binding.module,
505
+ binding.exportName,
506
+ sourceFile,
507
+ fileName,
508
+ element,
509
+ ));
510
+ continue;
511
+ }
512
+
513
+ const value = jsxAttributeInitializerToExpression(prop.initializer);
514
+ if (isUnsupportedFunctionPropValue(value)) {
515
+ diagnostics.push(createDiagnostic(
516
+ "MANDU_BOUNDARY_UNSUPPORTED_FUNCTION_PROP",
517
+ `Client boundary transform cannot serialize function prop "${name}" across the server/client boundary.`,
518
+ `Move "${name}" into the client component, call a server/API action from the client, or pass a serializable action identifier instead.`,
519
+ routeId,
520
+ boundaryId,
521
+ binding.module,
522
+ binding.exportName,
523
+ sourceFile,
524
+ fileName,
525
+ prop,
526
+ ));
527
+ continue;
528
+ }
529
+
530
+ const unsupportedValue = findUnsupportedStaticPropValue(value, sourceFile);
531
+ if (unsupportedValue) {
532
+ diagnostics.push(createDiagnostic(
533
+ "MANDU_BOUNDARY_UNSUPPORTED_PROP_VALUE",
534
+ `Client boundary transform cannot serialize prop "${name}" because ${unsupportedValue.reason}.`,
535
+ `Replace "${name}" with plain serializable data, or construct the non-serializable value inside the client component.`,
536
+ routeId,
537
+ boundaryId,
538
+ binding.module,
539
+ binding.exportName,
540
+ sourceFile,
541
+ fileName,
542
+ unsupportedValue.node,
543
+ ));
544
+ continue;
545
+ }
546
+
547
+ props.push(ts.factory.createPropertyAssignment(
548
+ createPropertyName(name),
549
+ value,
550
+ ));
551
+ }
552
+
553
+ return ts.factory.createObjectLiteralExpression(props, true);
554
+ }
555
+
556
+ function isUnsupportedFunctionPropValue(value: ts.Expression): boolean {
557
+ return ts.isArrowFunction(value) || ts.isFunctionExpression(value);
558
+ }
559
+
560
+ function findUnsupportedStaticPropValue(
561
+ value: ts.Expression,
562
+ sourceFile: ts.SourceFile,
563
+ ): { node: ts.Node; reason: string } | null {
564
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) {
565
+ return { node: value, reason: "functions are not serializable" };
566
+ }
567
+
568
+ if (ts.isClassExpression(value)) {
569
+ return { node: value, reason: "class instances and constructors are not serializable boundary props" };
570
+ }
571
+
572
+ if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) {
573
+ return { node: value, reason: "React elements cannot be serialized as client boundary props" };
574
+ }
575
+
576
+ if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && value.expression.text === "Symbol") {
577
+ return { node: value, reason: "symbols are not supported as client boundary props" };
578
+ }
579
+
580
+ if (ts.isObjectLiteralExpression(value)) {
581
+ for (const property of value.properties) {
582
+ if (ts.isMethodDeclaration(property) || ts.isGetAccessor(property) || ts.isSetAccessor(property)) {
583
+ return { node: property, reason: "object methods and accessors are not serializable" };
584
+ }
585
+ if (ts.isPropertyAssignment(property)) {
586
+ const nested = findUnsupportedStaticPropValue(property.initializer, sourceFile);
587
+ if (nested) return nested;
588
+ }
589
+ if (ts.isShorthandPropertyAssignment(property)) {
590
+ continue;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+
596
+ if (ts.isArrayLiteralExpression(value)) {
597
+ for (const element of value.elements) {
598
+ if (ts.isSpreadElement(element)) continue;
599
+ const nested = findUnsupportedStaticPropValue(element, sourceFile);
600
+ if (nested) return nested;
601
+ }
602
+ return null;
603
+ }
604
+
605
+ if (ts.isNewExpression(value)) {
606
+ const expressionText = value.expression.getText(sourceFile);
607
+ if (expressionText === "Date" || expressionText === "URL" || expressionText === "RegExp" || expressionText === "Map" || expressionText === "Set" || expressionText === "Error") {
608
+ return null;
609
+ }
610
+ return { node: value, reason: `new ${expressionText}(...) creates a non-plain object` };
611
+ }
612
+
613
+ return null;
614
+ }
615
+
616
+ function collectStaticPropKeys(attributes: ts.JsxAttributes): string[] {
617
+ const keys: string[] = [];
618
+ for (const prop of attributes.properties) {
619
+ if (!ts.isJsxAttribute(prop)) continue;
620
+ const name = getJsxAttributeName(prop.name, prop.getSourceFile());
621
+ if (name === "key" || name === "ref") continue;
622
+ keys.push(name);
623
+ }
624
+ return keys;
625
+ }
626
+
627
+ function jsxAttributeInitializerToExpression(
628
+ initializer: ts.JsxAttribute["initializer"],
629
+ ): ts.Expression {
630
+ if (!initializer) return ts.factory.createTrue();
631
+ if (ts.isStringLiteral(initializer)) return ts.factory.createStringLiteral(initializer.text);
632
+ if (ts.isJsxExpression(initializer)) {
633
+ return initializer.expression ?? ts.factory.createTrue();
634
+ }
635
+ return initializer as unknown as ts.Expression;
636
+ }
637
+
638
+ function addBoundaryImport(sourceFile: ts.SourceFile, boundaryImport: string): ts.SourceFile {
639
+ const boundaryImportDeclaration = ts.factory.createImportDeclaration(
640
+ undefined,
641
+ ts.factory.createImportClause(
642
+ false,
643
+ undefined,
644
+ ts.factory.createNamedImports([
645
+ ts.factory.createImportSpecifier(
646
+ false,
647
+ undefined,
648
+ ts.factory.createIdentifier(BOUNDARY_COMPONENT),
649
+ ),
650
+ ]),
651
+ ),
652
+ ts.factory.createStringLiteral(boundaryImport),
653
+ );
654
+ const statements = [...sourceFile.statements];
655
+ const insertAt = findImportInsertionIndex(statements);
656
+ statements.splice(insertAt, 0, boundaryImportDeclaration);
657
+ return ts.factory.updateSourceFile(sourceFile, statements);
658
+ }
659
+
660
+ function findImportInsertionIndex(statements: readonly ts.Statement[]): number {
661
+ let index = 0;
662
+ while (index < statements.length && isUseDirective(statements[index])) {
663
+ index++;
664
+ }
665
+ return index;
666
+ }
667
+
668
+ function isUseDirective(statement: ts.Statement | undefined): boolean {
669
+ return !!statement &&
670
+ ts.isExpressionStatement(statement) &&
671
+ ts.isStringLiteral(statement.expression);
672
+ }
673
+
674
+ function isMeaningfulJsxChild(child: ts.JsxChild): boolean {
675
+ if (ts.isJsxText(child)) return child.getText().trim().length > 0;
676
+ if (ts.isJsxExpression(child)) return !!child.expression;
677
+ return true;
678
+ }
679
+
680
+ function createStringJsxAttribute(name: string, value: string): ts.JsxAttribute {
681
+ return ts.factory.createJsxAttribute(
682
+ ts.factory.createIdentifier(name),
683
+ ts.factory.createStringLiteral(value),
684
+ );
685
+ }
686
+
687
+ function createPropertyName(name: string): ts.PropertyName {
688
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
689
+ ? ts.factory.createIdentifier(name)
690
+ : ts.factory.createStringLiteral(name);
691
+ }
692
+
693
+ function getJsxAttributeName(name: ts.JsxAttributeName, sourceFile: ts.SourceFile): string {
694
+ return ts.isIdentifier(name) ? name.text : name.getText(sourceFile);
695
+ }
696
+
697
+ function createDiagnostic(
698
+ code: ClientBoundaryDiagnostic["code"],
699
+ message: string,
700
+ suggestion: string,
701
+ routeId: string,
702
+ boundaryId: string | undefined,
703
+ module: string | undefined,
704
+ exportName: string | undefined,
705
+ sourceFile: ts.SourceFile,
706
+ fileName: string,
707
+ node: ts.Node,
708
+ ): ClientBoundaryDiagnostic {
709
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
710
+ return {
711
+ code,
712
+ severity: "error",
713
+ message,
714
+ suggestion,
715
+ routeId,
716
+ boundaryId,
717
+ module,
718
+ exportName,
719
+ source: {
720
+ file: fileName,
721
+ line: position.line + 1,
722
+ column: position.character + 1,
723
+ },
724
+ };
725
+ }
726
+
727
+ export function formatClientBoundaryDiagnostic(diagnostic: ClientBoundaryDiagnostic): string {
728
+ const context = [
729
+ `route=${diagnostic.routeId}`,
730
+ diagnostic.boundaryId ? `boundary=${diagnostic.boundaryId}` : undefined,
731
+ diagnostic.module ? `module=${diagnostic.module}` : undefined,
732
+ diagnostic.exportName ? `export=${diagnostic.exportName}` : undefined,
733
+ ].filter(Boolean).join(" ");
734
+ return `${diagnostic.code} ${diagnostic.source.file}:${diagnostic.source.line}:${diagnostic.source.column} ${context} ${diagnostic.message} Suggestion: ${diagnostic.suggestion}`;
735
+ }
736
+
737
+ export function formatClientBoundaryDiagnostics(diagnostics: readonly ClientBoundaryDiagnostic[]): string {
738
+ return diagnostics.map(formatClientBoundaryDiagnostic).join("\n");
739
+ }
740
+
741
+ export function assertNoClientBoundaryDiagnostics(diagnostics: readonly ClientBoundaryDiagnostic[]): void {
742
+ if (diagnostics.length === 0) return;
743
+ throw new Error(`Mandu client boundary transform failed:\n${formatClientBoundaryDiagnostics(diagnostics)}`);
744
+ }
745
+
746
+ export function validateClientBoundaryExport(
747
+ clientSource: string,
748
+ boundary: Pick<ClientBoundaryRecord, "id" | "routeId" | "module" | "exportName" | "source">,
749
+ fileName = boundary.module,
750
+ ): { status: ClientBoundaryExportValidationStatus; diagnostic?: ClientBoundaryDiagnostic } {
751
+ const sourceFile = ts.createSourceFile(
752
+ fileName,
753
+ clientSource,
754
+ ts.ScriptTarget.Latest,
755
+ true,
756
+ fileName.endsWith(".jsx") || fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
757
+ );
758
+ const status = hasRuntimeExport(sourceFile, boundary.exportName);
759
+ if (status !== "missing") return { status };
760
+
761
+ return {
762
+ status,
763
+ diagnostic: {
764
+ code: "MANDU_BOUNDARY_UNRESOLVED_EXPORT",
765
+ severity: "error",
766
+ message: `Client boundary module "${boundary.module}" does not export "${boundary.exportName}".`,
767
+ suggestion: boundary.exportName === "default"
768
+ ? "Add a default export to the client module, or import a named client export from the server route."
769
+ : `Export "${boundary.exportName}" from the client module, or update the server route to import an existing client export.`,
770
+ routeId: boundary.routeId,
771
+ boundaryId: boundary.id,
772
+ module: boundary.module,
773
+ exportName: boundary.exportName,
774
+ source: boundary.source,
775
+ },
776
+ };
777
+ }
778
+
779
+ export function validateClientBoundaryServerOnlyImports(
780
+ clientSource: string,
781
+ boundary: Pick<ClientBoundaryRecord, "id" | "routeId" | "module" | "exportName">,
782
+ fileName = boundary.module,
783
+ ): ClientBoundaryDiagnostic[] {
784
+ const sourceFile = ts.createSourceFile(
785
+ fileName,
786
+ clientSource,
787
+ ts.ScriptTarget.Latest,
788
+ true,
789
+ fileName.endsWith(".jsx") || fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
790
+ );
791
+ const diagnostics: ClientBoundaryDiagnostic[] = [];
792
+ const seen = new Set<string>();
793
+
794
+ const addDiagnostic = (specifier: string, node: ts.Node): void => {
795
+ const normalized = normalizeServerOnlyModuleSpecifier(specifier);
796
+ if (!normalized) return;
797
+ const key = `${normalized}:${node.getStart(sourceFile)}`;
798
+ if (seen.has(key)) return;
799
+ seen.add(key);
800
+
801
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
802
+ diagnostics.push({
803
+ code: "MANDU_BOUNDARY_SERVER_ONLY_IMPORT",
804
+ severity: "error",
805
+ message: `Client boundary module "${boundary.module}" imports server-only module "${specifier}".`,
806
+ suggestion: `Move "${specifier}" usage into a server route, loader, API, or server wrapper, then pass plain serializable data into the client component.`,
807
+ routeId: boundary.routeId,
808
+ boundaryId: boundary.id,
809
+ module: boundary.module,
810
+ exportName: boundary.exportName,
811
+ source: {
812
+ file: fileName,
813
+ line: position.line + 1,
814
+ column: position.character + 1,
815
+ },
816
+ });
817
+ };
818
+
819
+ const visit = (node: ts.Node): void => {
820
+ if (ts.isImportDeclaration(node)) {
821
+ if (node.importClause?.isTypeOnly) return;
822
+ const moduleSpecifier = getStringModuleSpecifier(node);
823
+ if (moduleSpecifier) addDiagnostic(moduleSpecifier, node.moduleSpecifier);
824
+ } else if (ts.isExportDeclaration(node)) {
825
+ if (node.isTypeOnly) return;
826
+ const moduleSpecifier = node.moduleSpecifier;
827
+ if (moduleSpecifier && ts.isStringLiteral(moduleSpecifier)) {
828
+ addDiagnostic(moduleSpecifier.text, moduleSpecifier);
829
+ }
830
+ } else if (
831
+ ts.isImportEqualsDeclaration(node) &&
832
+ ts.isExternalModuleReference(node.moduleReference) &&
833
+ ts.isStringLiteral(node.moduleReference.expression)
834
+ ) {
835
+ addDiagnostic(node.moduleReference.expression.text, node.moduleReference.expression);
836
+ } else if (ts.isCallExpression(node)) {
837
+ const specifier = firstStringArgument(node);
838
+ if (specifier && isRequireOrDynamicImport(node.expression)) {
839
+ addDiagnostic(specifier, node.arguments[0]);
840
+ }
841
+ }
842
+
843
+ ts.forEachChild(node, visit);
844
+ };
845
+
846
+ visit(sourceFile);
847
+ return diagnostics;
848
+ }
849
+
850
+ function normalizeServerOnlyModuleSpecifier(specifier: string): string | null {
851
+ const normalized = specifier.replace(/\\/g, "/");
852
+ if (normalized === "server-only") return normalized;
853
+ if (normalized === "bun" || normalized.startsWith("bun:")) return normalized;
854
+ if (normalized.startsWith("node:")) return normalized;
855
+ if (normalized === "@mandujs/core" || (normalized.startsWith("@mandujs/core/") && !normalized.startsWith("@mandujs/core/client"))) {
856
+ return normalized;
857
+ }
858
+ if (/\.server(?:\.[cm]?[jt]sx?)?$/.test(normalized)) return normalized;
859
+
860
+ const [head] = normalized.split("/");
861
+ if (head && SERVER_ONLY_MODULE_SPECIFIERS.has(head)) return head;
862
+ return null;
863
+ }
864
+
865
+ function firstStringArgument(node: ts.CallExpression): string | null {
866
+ const first = node.arguments[0];
867
+ return first && ts.isStringLiteral(first) ? first.text : null;
868
+ }
869
+
870
+ function isRequireOrDynamicImport(expression: ts.Expression): boolean {
871
+ return (
872
+ (ts.isIdentifier(expression) && expression.text === "require") ||
873
+ expression.kind === ts.SyntaxKind.ImportKeyword
874
+ );
875
+ }
876
+
877
+ function hasRuntimeExport(sourceFile: ts.SourceFile, exportName: string): ClientBoundaryExportValidationStatus {
878
+ let sawUnknownReExport = false;
879
+
880
+ for (const statement of sourceFile.statements) {
881
+ if (exportName === "default" && hasDefaultExport(statement)) {
882
+ return "found";
883
+ }
884
+
885
+ if (exportName !== "default" && hasNamedRuntimeExport(statement, exportName)) {
886
+ return "found";
887
+ }
888
+
889
+ if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) {
890
+ sawUnknownReExport = true;
891
+ }
892
+ }
893
+
894
+ return sawUnknownReExport ? "unknown" : "missing";
895
+ }
896
+
897
+ function hasDefaultExport(statement: ts.Statement): boolean {
898
+ if (ts.isExportAssignment(statement) && !statement.isExportEquals) return true;
899
+ return hasModifier(statement, ts.SyntaxKind.ExportKeyword) &&
900
+ hasModifier(statement, ts.SyntaxKind.DefaultKeyword);
901
+ }
902
+
903
+ function hasNamedRuntimeExport(statement: ts.Statement, exportName: string): boolean {
904
+ if (hasModifier(statement, ts.SyntaxKind.ExportKeyword)) {
905
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === exportName) {
906
+ return true;
907
+ }
908
+
909
+ if (ts.isVariableStatement(statement)) {
910
+ for (const declaration of statement.declarationList.declarations) {
911
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === exportName) {
912
+ return true;
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ if (ts.isExportDeclaration(statement)) {
919
+ if (!statement.exportClause) return statement.moduleSpecifier ? false : "default" === exportName;
920
+ if (!ts.isNamedExports(statement.exportClause)) return false;
921
+
922
+ for (const element of statement.exportClause.elements) {
923
+ const exportedName = element.name.text;
924
+ if (exportedName === exportName) return !statement.moduleSpecifier;
925
+ }
926
+ }
927
+
928
+ return false;
929
+ }
930
+
931
+ function hasModifier(node: ts.Node, kind: ts.SyntaxKind.ExportKeyword | ts.SyntaxKind.DefaultKeyword): boolean {
932
+ return !!ts.canHaveModifiers(node) && !!ts.getModifiers(node)?.some((modifier) => modifier.kind === kind);
933
+ }
934
+
935
+ function getStringModuleSpecifier(importDeclaration: ts.ImportDeclaration): string | null {
936
+ return ts.isStringLiteral(importDeclaration.moduleSpecifier)
937
+ ? importDeclaration.moduleSpecifier.text
938
+ : null;
939
+ }
940
+
941
+ export function collectStaticImportSpecifiers(source: string, fileName = "module.tsx"): string[] {
942
+ const sourceFile = ts.createSourceFile(
943
+ fileName,
944
+ source,
945
+ ts.ScriptTarget.Latest,
946
+ true,
947
+ ts.ScriptKind.TSX,
948
+ );
949
+ const specifiers: string[] = [];
950
+
951
+ for (const statement of sourceFile.statements) {
952
+ if (ts.isImportDeclaration(statement)) {
953
+ if (statement.importClause?.isTypeOnly) continue;
954
+ const moduleSpecifier = getStringModuleSpecifier(statement);
955
+ if (moduleSpecifier) specifiers.push(moduleSpecifier);
956
+ continue;
957
+ }
958
+
959
+ if (ts.isExportDeclaration(statement)) {
960
+ if (statement.isTypeOnly) continue;
961
+ const moduleSpecifier = statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
962
+ ? statement.moduleSpecifier.text
963
+ : null;
964
+ if (moduleSpecifier) specifiers.push(moduleSpecifier);
965
+ }
966
+ }
967
+
968
+ return specifiers;
969
+ }
970
+
971
+ export function isClientBoundaryModuleSpecifier(specifier: string): boolean {
972
+ return CLIENT_MODULE_PATTERN.test(specifier.replace(/\\/g, "/"));
973
+ }
974
+
975
+ function isClientModuleSpecifier(specifier: string): boolean {
976
+ return isClientBoundaryModuleSpecifier(specifier);
977
+ }