@ismaelsoilet/synapsejs 1.9.0 → 1.11.1

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 (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. package/templates/starter/package.json +2 -2
package/src/mcp/server.ts CHANGED
@@ -28,10 +28,11 @@ import { machineContract } from '../compiler/contract';
28
28
  import { analyzeImpact } from '../compiler/impact-analyzer';
29
29
  import { rollbackSliceMigrations, runSliceMigrations } from '../compiler/migration-runner';
30
30
  import { runSliceOracles } from '../compiler/oracle-runner';
31
- import { type SliceTemplate, scaffoldCrud, scaffoldSlice } from '../compiler/scaffolder';
31
+ import { type SliceTemplate, scaffoldCrud, scaffoldDomain, scaffoldSlice } from '../compiler/scaffolder';
32
32
  import { checkSchemaDrift } from '../compiler/schema-drift';
33
33
  import { findSliceFiles, resolveSlicesDir } from '../compiler/slice-discovery';
34
34
  import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from '../compiler/slice-splitter';
35
+ import { validatePathSegment } from '../core/path-guard';
35
36
  import { SYNAPSE_VERSION } from '../version';
36
37
  import { MCP_TOOLS } from './tools';
37
38
 
@@ -414,6 +415,42 @@ export class SynapseMcpServer {
414
415
  2
415
416
  );
416
417
  }
418
+ } else if (toolName === 'synapse_scaffold_domain') {
419
+ const domainRes = scaffoldDomain(args.domain, this.root, args.fields);
420
+ if (!domainRes.ok) {
421
+ return {
422
+ jsonrpc: '2.0',
423
+ id,
424
+ result: {
425
+ isError: true,
426
+ content: [
427
+ {
428
+ type: 'text',
429
+ text: JSON.stringify(
430
+ {
431
+ status: 'FAIL',
432
+ code: domainRes.error.code,
433
+ message: domainRes.error.message,
434
+ candidates: domainRes.error.candidates
435
+ },
436
+ null,
437
+ 2
438
+ )
439
+ }
440
+ ]
441
+ }
442
+ };
443
+ }
444
+
445
+ contentText = JSON.stringify(
446
+ {
447
+ status: 'PASS',
448
+ domain: args.domain,
449
+ createdFiles: domainRes.value.map((f) => path.relative(this.root, f))
450
+ },
451
+ null,
452
+ 2
453
+ );
417
454
  } else if (toolName === 'synapse_contract') {
418
455
  contentText = JSON.stringify(machineContract(), null, 2);
419
456
  } else if (toolName === 'synapse_migrate') {
@@ -502,6 +539,225 @@ export class SynapseMcpServer {
502
539
  const transcriptTail = String(args.transcriptTail || '');
503
540
  const result: NudgeGateResult = await shouldNudgeContinuation(transcriptTail);
504
541
  contentText = JSON.stringify(result, null, 2);
542
+ } else if (toolName === 'synapse_inspect_routes') {
543
+ const resolution = resolveSlicesDir(this.root);
544
+ if (!resolution.ok) {
545
+ return {
546
+ jsonrpc: '2.0',
547
+ id,
548
+ result: {
549
+ isError: true,
550
+ content: [
551
+ {
552
+ type: 'text',
553
+ text: JSON.stringify({ status: 'FAIL', code: resolution.error.code }, null, 2)
554
+ }
555
+ ]
556
+ }
557
+ };
558
+ }
559
+ const sliceFiles = findSliceFiles(resolution.value.slicesDir);
560
+ const routes = sliceFiles.map((file) => {
561
+ const rel = path.relative(resolution.value.slicesDir, file);
562
+ const parts = rel.split(path.sep);
563
+ const domain = parts[0] || 'general';
564
+ const name = path.basename(file, '.slice.tsx');
565
+ const code = fs.readFileSync(file, 'utf-8');
566
+ return {
567
+ sliceKey: `${domain}/${name}`,
568
+ routePath: `/${domain}/${name}`,
569
+ rpcPath: `/_synapse/rpc/${domain}/${name}`,
570
+ dynamicPath: `/${domain}/${name}/:id`,
571
+ filePath: toRelativePath(this.root, file),
572
+ hasAction: /export\s+async\s+function\s+\w+Action\b/.test(code),
573
+ hasLoader: /export\s+async\s+function\s+\w+Loader\b/.test(code),
574
+ hasComponent: /export\s+function\s+\w+(Trigger|View|Form|Component)\b/.test(code),
575
+ hasCache: /export\s+const\s+sliceCache\s*=/.test(code),
576
+ hasJob: /\bdefineJob\s*[<(]/.test(code)
577
+ };
578
+ });
579
+ contentText = JSON.stringify(
580
+ {
581
+ status: 'PASS',
582
+ operation: 'INSPECT_ROUTES',
583
+ totalRoutes: routes.length,
584
+ routes
585
+ },
586
+ null,
587
+ 2
588
+ );
589
+ } else if (toolName === 'synapse_test_slice') {
590
+ const targetSlice = String(args.targetSlice || '');
591
+ if (!targetSlice) {
592
+ return {
593
+ jsonrpc: '2.0',
594
+ id,
595
+ result: {
596
+ isError: true,
597
+ content: [
598
+ {
599
+ type: 'text',
600
+ text: JSON.stringify({ status: 'FAIL', code: 'MISSING_TARGET_SLICE' }, null, 2)
601
+ }
602
+ ]
603
+ }
604
+ };
605
+ }
606
+ const report = await runSliceOracles(this.root, { targetSlice });
607
+ contentText = JSON.stringify(report, null, 2);
608
+ } else if (toolName === 'synapse_generate_seed') {
609
+ const targetSlice = String(args.targetSlice || '');
610
+ const count = Math.min(Math.max(Number(args.count) || 5, 1), 50);
611
+ const resolution = resolveSlicesDir(this.root);
612
+ if (!resolution.ok) {
613
+ return {
614
+ jsonrpc: '2.0',
615
+ id,
616
+ result: {
617
+ isError: true,
618
+ content: [
619
+ {
620
+ type: 'text',
621
+ text: JSON.stringify({ status: 'FAIL', code: resolution.error.code }, null, 2)
622
+ }
623
+ ]
624
+ }
625
+ };
626
+ }
627
+ const sliceFiles = findSliceFiles(resolution.value.slicesDir);
628
+ const matchedFile = sliceFiles.find((f) => {
629
+ const name = path.basename(f, '.slice.tsx');
630
+ const rel = path.relative(resolution.value.slicesDir, f);
631
+ return name === targetSlice || rel.startsWith(targetSlice) || rel.includes(targetSlice);
632
+ });
633
+ if (!matchedFile) {
634
+ contentText = JSON.stringify(
635
+ {
636
+ status: 'FAIL',
637
+ code: 'SLICE_NOT_FOUND',
638
+ message: `Fatia "${targetSlice}" não encontrada para gerar seed.`
639
+ },
640
+ null,
641
+ 2
642
+ );
643
+ } else {
644
+ const code = fs.readFileSync(matchedFile, 'utf-8');
645
+ const tableMatch = code.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z0-9_]+)/i);
646
+ const tableName = tableMatch
647
+ ? tableMatch[1]
648
+ : path.basename(matchedFile, '.slice.tsx').replace(/-/g, '_');
649
+ const { getDatabase } = await import('../core/database-factory');
650
+ const dbClient = getDatabase();
651
+ const generatedRows: Record<string, unknown>[] = [];
652
+ let insertedCount = 0;
653
+ for (let i = 1; i <= count; i++) {
654
+ const row = {
655
+ id: crypto.randomUUID(),
656
+ name: `Seed Item ${i}`,
657
+ sku: `SEED-${Math.random().toString(36).substring(2, 7).toUpperCase()}`,
658
+ price: parseFloat((10 + Math.random() * 90).toFixed(2)),
659
+ stock: Math.floor(1 + Math.random() * 100),
660
+ status: i % 3 === 0 ? 'low_stock' : 'active',
661
+ created_at: new Date().toISOString()
662
+ };
663
+ generatedRows.push(row);
664
+ try {
665
+ await dbClient.insert(tableName, row);
666
+ insertedCount++;
667
+ } catch {
668
+ try {
669
+ await dbClient.query(`INSERT INTO ${tableName} (id, name) VALUES ($1, $2)`, [row.id, row.name]);
670
+ insertedCount++;
671
+ } catch {}
672
+ }
673
+ }
674
+ await dbClient.close?.();
675
+ contentText = JSON.stringify(
676
+ {
677
+ status: insertedCount > 0 ? 'PASS' : 'FAIL',
678
+ operation: 'GENERATE_SEED',
679
+ slice: targetSlice,
680
+ table: tableName,
681
+ ...(insertedCount > 0 ? {} : { code: 'SEED_INSERT_FAILED' }),
682
+ recordsSynthesized: count,
683
+ recordsInserted: insertedCount,
684
+ sample: generatedRows[0]
685
+ },
686
+ null,
687
+ 2
688
+ );
689
+ }
690
+ } else if (toolName === 'synapse_scaffold_component') {
691
+ const domain = String(args.domain || 'general');
692
+ const name = String(args.name || 'component');
693
+ const componentType = String(args.componentType || 'modal');
694
+
695
+ const domainValid = validatePathSegment(domain);
696
+ const nameValid = validatePathSegment(name);
697
+ if (!domainValid.ok || !nameValid.ok) {
698
+ return {
699
+ jsonrpc: '2.0',
700
+ id,
701
+ result: {
702
+ isError: true,
703
+ content: [
704
+ {
705
+ type: 'text',
706
+ text: JSON.stringify({ status: 'FAIL', code: 'INVALID_PATH_SEGMENT' }, null, 2)
707
+ }
708
+ ]
709
+ }
710
+ };
711
+ }
712
+
713
+ const resolution = resolveSlicesDir(this.root);
714
+ let slicesDir: string;
715
+ if (resolution.ok) {
716
+ slicesDir = resolution.value.slicesDir;
717
+ } else if (resolution.error.code === 'AMBIGUOUS_SLICES_DIR') {
718
+ return {
719
+ jsonrpc: '2.0',
720
+ id,
721
+ result: {
722
+ isError: true,
723
+ content: [
724
+ {
725
+ type: 'text',
726
+ text: JSON.stringify({ status: 'FAIL', code: resolution.error.code }, null, 2)
727
+ }
728
+ ]
729
+ }
730
+ };
731
+ } else {
732
+ slicesDir = path.join(path.resolve(this.root), 'src', 'slices');
733
+ }
734
+ const outPath = path.join(slicesDir, domain, `${name}.tsx`);
735
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
736
+ const pascal = name
737
+ .split(/[-_]/)
738
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
739
+ .join('');
740
+ let componentCode = '';
741
+ if (componentType === 'modal') {
742
+ componentCode = `import React, { useState } from 'react';\nimport { Modal, Button } from 'synapsejs/client';\n\nexport function ${pascal}() {\n const [isOpen, setIsOpen] = useState(false);\n return (\n <div>\n <Button onClick={() => setIsOpen(true)}>Abrir ${pascal}</Button>\n <Modal isOpen={isOpen} onClose={() => setIsOpen(false)} title="${pascal}">\n <p className="text-slate-300">Conteúdo interativo do modal gerenciado no cliente.</p>\n </Modal>\n </div>\n );\n}\n`;
743
+ } else if (componentType === 'drawer') {
744
+ componentCode = `import React, { useState } from 'react';\nimport { Drawer, Button } from 'synapsejs/client';\n\nexport function ${pascal}() {\n const [isOpen, setIsOpen] = useState(false);\n return (\n <div>\n <Button onClick={() => setIsOpen(true)}>Exibir ${pascal}</Button>\n <Drawer isOpen={isOpen} onClose={() => setIsOpen(false)} title="${pascal}">\n <p className="text-slate-300">Painel lateral deslizante.</p>\n </Drawer>\n </div>\n );\n}\n`;
745
+ } else if (componentType === 'tabs') {
746
+ componentCode = `import React from 'react';\nimport { Tabs, TabList, Tab, TabPanel } from 'synapsejs/client';\n\nexport function ${pascal}() {\n return (\n <Tabs defaultValue="tab1">\n <TabList>\n <Tab value="tab1">Aba 1</Tab>\n <Tab value="tab2">Aba 2</Tab>\n </TabList>\n <TabPanel value="tab1"><div className="p-4 text-slate-300">Painel 1</div></TabPanel>\n <TabPanel value="tab2"><div className="p-4 text-slate-300">Painel 2</div></TabPanel>\n </Tabs>\n );\n}\n`;
747
+ } else {
748
+ componentCode = `import React from 'react';\nimport { DropdownMenu, DropdownItem, Button } from 'synapsejs/client';\n\nexport function ${pascal}() {\n return (\n <DropdownMenu trigger={<Button variant="secondary">Ações ▾</Button>}>\n <DropdownItem onClick={() => alert('Opção 1')}>Opção 1</DropdownItem>\n <DropdownItem variant="danger" onClick={() => alert('Excluir')}>Excluir</DropdownItem>\n </DropdownMenu>\n );\n}\n`;
749
+ }
750
+ fs.writeFileSync(outPath, componentCode, 'utf-8');
751
+ contentText = JSON.stringify(
752
+ {
753
+ status: 'PASS',
754
+ operation: 'SCAFFOLD_COMPONENT',
755
+ componentType,
756
+ filePath: toRelativePath(this.root, outPath)
757
+ },
758
+ null,
759
+ 2
760
+ );
505
761
  } else {
506
762
  return {
507
763
  jsonrpc: '2.0',
package/src/mcp/tools.ts CHANGED
@@ -73,7 +73,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
73
73
  },
74
74
  template: {
75
75
  type: 'string',
76
- enum: ['create', 'list', 'update', 'delete', 'login', 'oauth-github', 'crud'],
76
+ enum: ['create', 'list', 'update', 'delete', 'login', 'oauth-github', 'crud', 'modal-crud'],
77
77
  description: 'Optional slice template shape (default: create)'
78
78
  },
79
79
  fields: {
@@ -84,6 +84,27 @@ export const MCP_TOOLS: McpToolDefinition[] = [
84
84
  }
85
85
  }
86
86
  },
87
+ {
88
+ name: 'synapse_scaffold_domain',
89
+ description:
90
+ 'Scaffold an entire modular domain directory with domain layout (_layout.tsx), initial list slice, and shared module (src/shared/<domain>.ts)',
91
+ inputSchema: {
92
+ type: 'object',
93
+ required: ['domain'],
94
+ properties: {
95
+ domain: {
96
+ type: 'string',
97
+ pattern: PATH_SEGMENT_PATTERN,
98
+ description: 'Domain name (e.g. billing, customers, orders)'
99
+ },
100
+ fields: {
101
+ type: 'string',
102
+ description:
103
+ 'Optional initial fields grammar, e.g. "name:string,email:string,status:enum(ACTIVE|INACTIVE),price:number"'
104
+ }
105
+ }
106
+ }
107
+ },
87
108
  {
88
109
  name: 'synapse_contract',
89
110
  description:
@@ -231,6 +252,76 @@ export const MCP_TOOLS: McpToolDefinition[] = [
231
252
  }
232
253
  }
233
254
  }
255
+ },
256
+ {
257
+ name: 'synapse_inspect_routes',
258
+ description:
259
+ 'Inspect all active slice routes, dynamic path parameters, HTTP verbs, caching policies, and registered RPC endpoints as structured JSON for AI context',
260
+ inputSchema: {
261
+ type: 'object',
262
+ properties: {}
263
+ }
264
+ },
265
+ {
266
+ name: 'synapse_test_slice',
267
+ description:
268
+ 'Execute Fast-Check Property-Based Testing (PBT) invariant test suite for one specific targeted slice without running the entire codebase',
269
+ inputSchema: {
270
+ type: 'object',
271
+ required: ['targetSlice'],
272
+ properties: {
273
+ targetSlice: {
274
+ type: 'string',
275
+ description:
276
+ 'Name or key of the slice to run oracles for (e.g. overview, create-item, or inventory/create-item)'
277
+ }
278
+ }
279
+ }
280
+ },
281
+ {
282
+ name: 'synapse_generate_seed',
283
+ description:
284
+ 'Synthesize realistic, valid TypeBox-compliant database seed records using Fast-Check arbitraries directly into the live SQLite/PostgreSQL database',
285
+ inputSchema: {
286
+ type: 'object',
287
+ required: ['targetSlice'],
288
+ properties: {
289
+ targetSlice: {
290
+ type: 'string',
291
+ description: 'Name or key of the slice whose table to seed (e.g. create-item or inventory/create-item)'
292
+ },
293
+ count: {
294
+ type: 'number',
295
+ description: 'Number of seed records to synthesize and insert (default: 5)'
296
+ }
297
+ }
298
+ }
299
+ },
300
+ {
301
+ name: 'synapse_scaffold_component',
302
+ description:
303
+ 'Scaffold interactive client sub-components (with Modal, Drawer, Tabs or Dropdown) ready to be embedded or transcluded via SubSlice',
304
+ inputSchema: {
305
+ type: 'object',
306
+ required: ['domain', 'name', 'componentType'],
307
+ properties: {
308
+ domain: {
309
+ type: 'string',
310
+ pattern: PATH_SEGMENT_PATTERN,
311
+ description: 'Domain name (e.g. billing, inventory, settings)'
312
+ },
313
+ name: {
314
+ type: 'string',
315
+ pattern: PATH_SEGMENT_PATTERN,
316
+ description: 'Component name in kebab-case (e.g. item-details-modal)'
317
+ },
318
+ componentType: {
319
+ type: 'string',
320
+ enum: ['modal', 'drawer', 'tabs', 'dropdown'],
321
+ description: 'Interactive primitive type to scaffold'
322
+ }
323
+ }
324
+ }
234
325
  }
235
326
  ];
236
327
 
@@ -279,6 +279,109 @@ export const FEATURE_STATUS: FeatureStatus[] = [
279
279
  status: 'stable',
280
280
  evidence: 'bun test packages/synapse/test/feature-exercise.test.ts (rollback rolls back a real statement)'
281
281
  },
282
+ {
283
+ feature: 'Query aggregations, GROUP BY and portable HAVING',
284
+ status: 'stable',
285
+ evidence:
286
+ 'bun test packages/synapse/test/query-builder.test.ts; TEST_DATABASE_URL=... bun test packages/synapse/test/postgres-adapters.test.ts'
287
+ },
288
+ {
289
+ feature: 'Cron scheduling with POSIX DOM/DOW semantics and runAt',
290
+ status: 'stable',
291
+ evidence: 'bun test packages/synapse/test/cron-queue.test.ts'
292
+ },
293
+ {
294
+ feature: 'PostgreSQL cron scope fails with CRON_UNSUPPORTED_ENGINE',
295
+ status: 'stable',
296
+ evidence: 'TEST_DATABASE_URL=... bun test packages/synapse/test/postgres-adapters.test.ts'
297
+ },
298
+ {
299
+ feature: 'Fine-grained permissions inside signed session tokens',
300
+ status: 'stable',
301
+ evidence: 'bun test packages/synapse/test/session-security.test.ts'
302
+ },
303
+ {
304
+ feature: 'OAuth PKCE, state validation and bounded token exchange',
305
+ status: 'stable',
306
+ evidence: 'bun test packages/synapse/test/session-security.test.ts'
307
+ },
308
+ {
309
+ feature: 'Local CSS bundle without a CDN',
310
+ status: 'stable',
311
+ evidence: 'bun test packages/synapse/test/css-bundle.test.ts'
312
+ },
313
+ {
314
+ feature: 'Client error boundary for slice render failures',
315
+ status: 'stable',
316
+ evidence: 'bun test packages/synapse/test/hydration.test.tsx'
317
+ },
318
+ {
319
+ feature: 'SPA morphing preserves focus and form state',
320
+ status: 'stable',
321
+ evidence: 'bun test packages/synapse/test/turbo-router.test.tsx'
322
+ },
323
+ {
324
+ feature: 'Transactional domain scaffolding (new-domain)',
325
+ status: 'stable',
326
+ evidence: 'bun test packages/synapse/test/scaffolder.test.ts'
327
+ },
328
+ {
329
+ feature: 'Large-slice warning (WARN_LARGE_SLICE)',
330
+ status: 'stable',
331
+ evidence: 'bun test packages/synapse/test/slice-splitter.test.ts'
332
+ },
333
+ {
334
+ feature: 'TypeScript compiler API guard (COMPILER_API_UNAVAILABLE)',
335
+ status: 'stable',
336
+ evidence: 'bun test packages/synapse/test/compiler-api.test.ts'
337
+ },
338
+ {
339
+ feature: 'Accessible UI primitives (Modal, Drawer, Tabs, Dropdown, Toast)',
340
+ status: 'stable',
341
+ evidence:
342
+ 'bun test packages/synapse/test/ui-primitives.test.tsx packages/synapse/test/ui-primitives-client.test.tsx'
343
+ },
344
+ {
345
+ feature: 'Isomorphic dynamic route parameters (useParams/useSearchParams)',
346
+ status: 'stable',
347
+ evidence:
348
+ 'bun test packages/synapse/test/dynamic-routes.test.tsx packages/synapse/test/dynamic-routes-client.test.tsx'
349
+ },
350
+ {
351
+ feature: 'Streaming HTTP responses (ctx.stream, chunked transfer)',
352
+ status: 'stable',
353
+ evidence: 'bun test packages/synapse/test/streaming-oauth.test.ts'
354
+ },
355
+ {
356
+ feature: 'Client upload hook with progress and abort (useUpload)',
357
+ status: 'stable',
358
+ evidence: 'bun test packages/synapse/test/upload-hook.test.tsx'
359
+ },
360
+ {
361
+ feature: 'Multi-provider OAuth presets and profile normalization (Google/GitHub/Discord)',
362
+ status: 'stable',
363
+ evidence: 'bun test packages/synapse/test/streaming-oauth.test.ts'
364
+ },
365
+ {
366
+ feature: 'Native MCP server with 23 tools, including route/seed/slice/component tools',
367
+ status: 'stable',
368
+ evidence: 'bun test packages/synapse/test/mcp-server.test.ts'
369
+ },
370
+ {
371
+ feature: 'Modal CRUD slice scaffolding (--template=modal-crud)',
372
+ status: 'stable',
373
+ evidence: 'bun test packages/synapse/test/scaffolder.test.ts'
374
+ },
375
+ {
376
+ feature: 'Vendor bundle auto-rebuild and client-primitive exports',
377
+ status: 'stable',
378
+ evidence: 'bun test packages/synapse/test/client-bundler.test.ts'
379
+ },
380
+ {
381
+ feature: 'Hydration URL synchronization and interactive modal/dropdown semantics',
382
+ status: 'stable',
383
+ evidence: 'bun test packages/synapse/test/client-entry.test.ts packages/synapse/test/ui-primitives-client.test.tsx'
384
+ },
282
385
  {
283
386
  feature: 'Incremental diagnostics cache across processes',
284
387
  status: 'roadmap',
@@ -45,13 +45,14 @@ export function clientEntrySource(options: ClientEntryOptions): string {
45
45
  `// [SYNAPSE-JS GENERATED CLIENT ENTRY] hydrates the slice in the browser`,
46
46
  `import React from 'react';`,
47
47
  `import { hydrateRoot } from 'react-dom/client';`,
48
- `import { rpcCall, SynapseProvider } from 'synapsejs/client';`,
48
+ `import { rpcCall, SynapseProvider, ToastProvider } from 'synapsejs/client';`,
49
49
  ...layoutImports,
50
50
  `import { ${options.componentName} } from ${JSON.stringify(options.clientModulePath)};`,
51
51
  ``,
52
52
  `export function hydrate() {`,
53
53
  ` const scope = globalThis as unknown as Record<string, unknown>;`,
54
54
  ` const props = (scope[${JSON.stringify(CLIENT_PROPS_GLOBAL)}] ?? {}) as Record<string, unknown>;`,
55
+ ` const currentUrl = (scope.__SYNAPSE_URL__ as string) || (typeof window !== 'undefined' ? window.location.href : '/');`,
55
56
  ` const root = document.getElementById(${JSON.stringify(rootId)});`,
56
57
  ` if (root) {`,
57
58
  ` const sliceElement = React.createElement(${options.componentName}, {`,
@@ -71,22 +72,27 @@ export function clientEntrySource(options: ClientEntryOptions): string {
71
72
  ` const RootLayout = (RootLayoutModule as any).default || (RootLayoutModule as any).Layout || null;`
72
73
  );
73
74
  }
75
+ lines.push(` const layoutProps = { ...props, url: currentUrl };`);
74
76
  let treeExpr = 'sliceElement';
75
77
  if (options.domainLayoutPath) {
76
- treeExpr = `(typeof DomainLayout === 'function' ? React.createElement(DomainLayout, { ...props }, ${treeExpr}) : ${treeExpr})`;
78
+ treeExpr = `(typeof DomainLayout === 'function' ? React.createElement(DomainLayout, layoutProps, ${treeExpr}) : ${treeExpr})`;
77
79
  }
78
80
  if (options.rootLayoutPath) {
79
- treeExpr = `(typeof RootLayout === 'function' ? React.createElement(RootLayout, { ...props }, ${treeExpr}) : ${treeExpr})`;
81
+ treeExpr = `(typeof RootLayout === 'function' ? React.createElement(RootLayout, layoutProps, ${treeExpr}) : ${treeExpr})`;
80
82
  }
81
83
  lines.push(` const treeWithLayout = ${treeExpr};`);
82
84
  lines.push(` hydrateRoot(`);
83
85
  lines.push(` root,`);
84
- lines.push(` React.createElement(SynapseProvider, { props, session: props.session as any }, treeWithLayout)`);
86
+ lines.push(
87
+ ` React.createElement(SynapseProvider, { props, session: props.session as any, url: currentUrl }, React.createElement(ToastProvider, null, treeWithLayout))`
88
+ );
85
89
  lines.push(` );`);
86
90
  } else {
87
91
  lines.push(` hydrateRoot(`);
88
92
  lines.push(` root,`);
89
- lines.push(` React.createElement(SynapseProvider, { props, session: props.session as any }, sliceElement)`);
93
+ lines.push(
94
+ ` React.createElement(SynapseProvider, { props, session: props.session as any, url: currentUrl }, React.createElement(ToastProvider, null, sliceElement))`
95
+ );
90
96
  lines.push(` );`);
91
97
  }
92
98