@ismaelsoilet/synapsejs 1.3.0 → 1.8.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 (38) hide show
  1. package/README.md +15 -8
  2. package/bin/synapse.ts +70 -3
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/migration-runner.ts +134 -9
  11. package/src/compiler/scaffolder.ts +62 -0
  12. package/src/compiler/schema-dag.ts +18 -11
  13. package/src/compiler/slice-splitter.ts +29 -4
  14. package/src/compiler/slice-templates.ts +10 -0
  15. package/src/compiler/standalone-builder.ts +16 -2
  16. package/src/core/action-context.ts +56 -0
  17. package/src/core/action-definition.ts +89 -0
  18. package/src/core/cache.ts +139 -0
  19. package/src/core/config.ts +37 -1
  20. package/src/core/index.ts +3 -0
  21. package/src/core/postgres-client.ts +6 -0
  22. package/src/core/postgres-queue.ts +27 -7
  23. package/src/core/query-builder.ts +75 -60
  24. package/src/core/session-context.ts +1 -1
  25. package/src/core/session-token.ts +7 -1
  26. package/src/core/sqlite-client.ts +26 -1
  27. package/src/core/storage.ts +1 -5
  28. package/src/core/websocket.ts +30 -0
  29. package/src/index.ts +8 -1
  30. package/src/mcp/server.ts +63 -1
  31. package/src/runtime/discovery-rules.ts +20 -1
  32. package/src/runtime/event-hub.ts +6 -1
  33. package/src/runtime/network-guard.ts +180 -0
  34. package/src/runtime/queue-engine.ts +55 -21
  35. package/src/runtime/rate-limiter.ts +14 -0
  36. package/src/runtime/server.ts +572 -139
  37. package/src/version.ts +4 -0
  38. package/templates/starter/.jev.json +1 -1
package/README.md CHANGED
@@ -21,7 +21,9 @@ Features are listed with the command that fails when they break. Nothing is docu
21
21
  without one — see the repository's `AGENTS.md` for the full contract.
22
22
 
23
23
  **Stable:** vertical slices (N = 1) · `Result<T, E>` flows · TypeBox JIT contracts · declarative
24
- `sliceSchema` migrations with DAG topological ordering · embedded SQLite (WAL, prepared-statement cache, CTE-safe) ·
24
+ `sliceSchema` migrations with DAG topological ordering · bidirectional `-- up:` / `-- down:` rollbacks (`synapse rollback`) · embedded SQLite (WAL, tuned busy_timeout & cache pragmas delivering 4,000+ req/s, CTE-safe) ·
25
+ full-duplex WebSockets (`defineSocket` + `useWebSocket`) · hierarchical sub-slice composition & outlets (`<SubSlice>`, `<SliceOutlet>`) · in-memory SSR micro-cache & ISR (`defineCache`) ·
26
+ vendor bundle code-splitting via Import Maps (`compiler.vendorChunks`) · standardized shared domain modules (`synapse new-shared`) ·
25
27
  isomorphic Query Builder with parameterized relational joins and sub-object nesting (`nestJoinedRow`) · centralized Schema Catalog (`.codebase/db-schema.d.ts`) ·
26
28
  declarative UI primitives & hooks (`DataTable`, `DataForm`, `useAction`, `useLoaderData`, `useSubscription`, `SynapseProvider`) ·
27
29
  realtime SSE gateway (`EventHub` + `ctx.broadcast`) · schema drift detector (`synapse db-drift`) · AST diff & cross-slice impact analysis (`synapse impact`) ·
@@ -31,9 +33,11 @@ with rawBody preservation · B2B multi-tenancy & IDOR prevention (`requireTenant
31
33
  and Turbo Morphing router · explicit RBAC via `requireAuth(session, roles)` · zero-wiring routing + SSR + RPC · slice discovery
32
34
  that never reports PASS with zero slices · isomorphic splitter with compile and leak gates · typed AST skeleton map ·
33
35
  scaffolder with `--fields` grammar and templates (create, list, update, delete, login, oauth-github, crud) · standalone production bundler (`synapse build --standalone`) ·
34
- MCP stdio server with 10 tools · slice invariants under `bun:test` with per-invariant reporting · PostgreSQL parity · real concurrency & load stress benchmarks.
36
+ MCP stdio server with 18 native tools (11 Synapse + 7 Jev) · slice invariants under `bun:test` with per-invariant reporting · PostgreSQL parity · real concurrency & load stress benchmarks ·
37
+ SSRF & Network Guard (`validateExternalUrl`, `isPrivateOrReservedIp`) · anti-spoofing & bounded LRU rate limiting · bounded LRU SSR micro-cache with parameter normalization · strict payload size guards (RPC 5MB, Webhook 10MB) & malformed JSON protection · SQLite queue zombie job recovery with `locked_at` visibility timeout · HTTP 500 SSR error isolation & bundle suppression · secure HttpOnly cookies (`ctx.setCookie`) · graceful HTTP shutdown (`server.stop` with connection drain) ·
38
+ Prototype Pollution Shield (`setNestedProperty`, `expandNestedObject`) · Parameterized Query Operator Allowlist (`VALID_WHERE_OPERATORS`) · Reentrant SQLite Savepoints (`txDepth` + `SAVEPOINT`) · Atomic Queue Poison-Pill Neutralization (claim increment & DLQ timeout) · Strict Multi-Tenant Context Derivation (spoofing-proof token claims) · Streaming Upload & Chunked DoS Armor (`readBodyWithinLimit`) · Origin-Validated WebSocket Upgrades (`SYNAPSE_ALLOWED_ORIGINS`) · Safe SSR Serialization (`<script type="application/json">`) & Cookie CRLF Guard.
35
39
 
36
- **Roadmap:** down-migrations / DDL rollbacks, multi-region distributed cache adapters (Redis/Dragonfly).
40
+ **Roadmap:** multi-node distributed cache adapters (Redis/Dragonfly cluster sync), multi-host worker clustering.
37
41
 
38
42
  The repository README carries a measured context-surface benchmark of the same two features
39
43
  implemented with and without the slice convention, including what the numbers do not show.
@@ -126,8 +130,9 @@ bun run mcp # JSON-RPC 2.0 over stdio
126
130
 
127
131
  Tools: `synapse_get_repo_map`, `synapse_get_db_schema`, `synapse_check`, `synapse_split`,
128
132
  `synapse_run_pbt`, `synapse_scaffold_slice`, `synapse_migrate`, `synapse_rollback`, `synapse_contract`,
129
- `synapse_check_db_drift`, `synapse_diff_impact`. Slice-dependent tools fail with `NO_SLICES_DIR` (listing the paths they examined)
130
- instead of reporting an empty success.
133
+ `synapse_check_db_drift`, `synapse_diff_impact`, `synapse_test_gate`, `synapse_abort_check`,
134
+ `synapse_verify_step`, `synapse_reasoning_effort`, `synapse_triage_error`, `synapse_route_task`, `synapse_evaluate_nudge`.
135
+ Slice-dependent tools fail with `NO_SLICES_DIR` (listing the paths they examined) instead of reporting an empty success.
131
136
 
132
137
  Client configuration for an agent host:
133
138
 
@@ -182,11 +187,13 @@ import {
182
187
 
183
188
  // Declarative UI Primitives, Hooks & i18n
184
189
  DataTable, DataForm, Button, Card, Badge, Pagination,
185
- SynapseProvider, useAction, useLoaderData, useSubscription, useSession, useSynapseContext,
190
+ SliceOutlet, SliceOutletProvider, SubSlice,
191
+ SynapseProvider, useAction, useLoaderData, useSubscription, useWebSocket, useSession, useSynapseContext,
186
192
  createTranslator,
187
193
 
188
- // Realtime SSE Gateway
194
+ // Realtime & Cache Gateways
189
195
  EventHub, getEventHub, resetEventHub,
196
+ defineSocket, defineCache,
190
197
 
191
198
  // Compiler, Scaffolding, Schemas, Drift & Diagnostics
192
199
  runSliceMigrations, rollbackSliceMigrations, runMachineVerifications,
@@ -194,7 +201,7 @@ import {
194
201
  checkSchemaDrift, analyzeImpact, nestJoinedRow,
195
202
  resolveSlicesDir, findSliceFiles, SLICE_EXTENSION,
196
203
  splitSlice, verifySplit, writeSplitArtifacts, artifactDirectory,
197
- scaffoldSlice, scaffoldCrud, parseFields, compressRepositoryAST, buildStandalone,
204
+ scaffoldSlice, scaffoldCrud, scaffoldShared, parseFields, compressRepositoryAST, buildStandalone,
198
205
 
199
206
  // MCP
200
207
  SynapseMcpServer
package/bin/synapse.ts CHANGED
@@ -28,7 +28,7 @@ import { generateDatabaseSchemaCatalog } from '../src/compiler/db-schema-generat
28
28
  import { analyzeImpact } from '../src/compiler/impact-analyzer';
29
29
  import { rollbackSliceMigrations, runSliceMigrations } from '../src/compiler/migration-runner';
30
30
  import { runSliceOracles } from '../src/compiler/oracle-runner';
31
- import { scaffoldCrud, scaffoldSlice } from '../src/compiler/scaffolder';
31
+ import { scaffoldCrud, scaffoldShared, scaffoldSlice } from '../src/compiler/scaffolder';
32
32
  import { checkSchemaDrift } from '../src/compiler/schema-drift';
33
33
  import { findSliceFiles, resolveSlicesDir } from '../src/compiler/slice-discovery';
34
34
  import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from '../src/compiler/slice-splitter';
@@ -442,6 +442,53 @@ async function main() {
442
442
  break;
443
443
  }
444
444
 
445
+ case 'new-shared': {
446
+ const name = process.argv[3];
447
+ if (!name) {
448
+ process.stderr.write(
449
+ `${JSON.stringify({
450
+ status: 'ERROR',
451
+ message: 'Nome do módulo compartilhado ausente. Uso: synapse new-shared <nome>'
452
+ })}\n`
453
+ );
454
+ process.exit(1);
455
+ }
456
+
457
+ const root = process.env.SYNAPSE_ROOT || process.cwd();
458
+ const created = scaffoldShared(name, root);
459
+
460
+ if (!created.ok) {
461
+ process.stderr.write(
462
+ `${JSON.stringify(
463
+ {
464
+ status: 'ERROR',
465
+ operation: 'SCAFFOLD_SHARED',
466
+ code: created.error.code,
467
+ message: created.error.message
468
+ },
469
+ null,
470
+ 2
471
+ )}\n`
472
+ );
473
+ process.exit(1);
474
+ }
475
+
476
+ process.stdout.write(
477
+ `${JSON.stringify(
478
+ {
479
+ status: 'PASS',
480
+ operation: 'SCAFFOLD_SHARED',
481
+ name,
482
+ createdPath: path.relative(root, created.value)
483
+ },
484
+ null,
485
+ 2
486
+ )}\n`
487
+ );
488
+ process.exit(0);
489
+ break;
490
+ }
491
+
445
492
  case 'build': {
446
493
  const isStandalone = process.argv.includes('--standalone');
447
494
 
@@ -707,7 +754,7 @@ async function main() {
707
754
  `${JSON.stringify(
708
755
  {
709
756
  framework: 'SynapseJS',
710
- version: '1.1.0',
757
+ version: '1.6.0',
711
758
  runtime: 'Bun + Bun.serve',
712
759
  database: 'Embedded SQLite (WAL) and PostgreSQL, both verified',
713
760
  protocols: ['REST/HTTP', 'Isomorphic RPC', 'Model Context Protocol (MCP)'],
@@ -877,6 +924,26 @@ async function main() {
877
924
  feature: 'Token revocation, TOTP 2FA, image optimization & plugin hooks',
878
925
  status: 'stable',
879
926
  evidence: 'bun test packages/synapse/test/onda4.test.ts'
927
+ },
928
+ {
929
+ feature: 'SSRF & Private Network Guard',
930
+ status: 'stable',
931
+ evidence: 'bun test packages/synapse/test/production-hardening.test.ts'
932
+ },
933
+ {
934
+ feature: 'Bounded LRU SSR Cache & Query Normalization',
935
+ status: 'stable',
936
+ evidence: 'bun test packages/synapse/test/production-hardening.test.ts'
937
+ },
938
+ {
939
+ feature: 'SQLite Queue Zombie Job Visibility Recovery',
940
+ status: 'stable',
941
+ evidence: 'bun test packages/synapse/test/production-hardening.test.ts'
942
+ },
943
+ {
944
+ feature: 'HttpOnly Cookie Authentication via ActionContext',
945
+ status: 'stable',
946
+ evidence: 'bun test packages/synapse/test/production-hardening.test.ts'
880
947
  }
881
948
  ],
882
949
  commands: [
@@ -910,7 +977,7 @@ async function main() {
910
977
 
911
978
  default: {
912
979
  process.stderr.write(
913
- `Unknown command: ${command}\nAvailable: new, dev, check, migrate, rollback, mcp, skeleton, db-schema, split, build, test, worker, new-slice, contract, info\n`
980
+ `Unknown command: ${command}\nAvailable: start, dev, check, migrate, rollback, mcp, skeleton, db-schema, db-drift, impact, split, build, test, worker, new, new-slice, contract, info\n`
914
981
  );
915
982
  process.exit(1);
916
983
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ismaelsoilet/synapsejs",
3
- "version": "1.3.0",
3
+ "version": "1.8.0",
4
4
  "description": "Bun-only fullstack framework where a feature is a single contiguous file",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -336,32 +336,49 @@ export interface DataFormProps {
336
336
  className?: string;
337
337
  }
338
338
 
339
- export function setNestedProperty(obj: Record<string, any>, path: string, value: unknown): void {
339
+ const FORBIDDEN_PROPERTIES = new Set(['__proto__', 'constructor', 'prototype']);
340
+
341
+ export function setNestedProperty(obj: Record<string, unknown>, path: string, value: unknown): void {
340
342
  const parts = path.split('.');
343
+ if (parts.some((p) => FORBIDDEN_PROPERTIES.has(p.trim()))) {
344
+ return;
345
+ }
341
346
  let cur = obj;
342
347
  for (let i = 0; i < parts.length - 1; i++) {
343
348
  const part = parts[i];
349
+ if (FORBIDDEN_PROPERTIES.has(part)) return;
344
350
  if (!cur[part] || typeof cur[part] !== 'object') {
345
- cur[part] = {};
351
+ cur[part] = Object.create(null);
346
352
  }
347
- cur = cur[part];
353
+ cur = cur[part] as Record<string, unknown>;
354
+ }
355
+ const lastKey = parts[parts.length - 1];
356
+ if (!FORBIDDEN_PROPERTIES.has(lastKey)) {
357
+ cur[lastKey] = value;
348
358
  }
349
- cur[parts[parts.length - 1]] = value;
350
359
  }
351
360
 
352
- export function getNestedProperty(obj: Record<string, any>, path: string): unknown {
361
+ export function getNestedProperty(obj: Record<string, unknown>, path: string): unknown {
353
362
  const parts = path.split('.');
354
- let cur: any = obj;
363
+ if (parts.some((p) => FORBIDDEN_PROPERTIES.has(p.trim()))) {
364
+ return undefined;
365
+ }
366
+ let cur: unknown = obj;
355
367
  for (const part of parts) {
356
- if (cur === null || cur === undefined) return undefined;
357
- cur = cur[part];
368
+ if (cur === null || cur === undefined || typeof cur !== 'object' || FORBIDDEN_PROPERTIES.has(part)) {
369
+ return undefined;
370
+ }
371
+ cur = (cur as Record<string, unknown>)[part];
358
372
  }
359
373
  return cur;
360
374
  }
361
375
 
362
376
  export function expandNestedObject(flat: Record<string, unknown>): Record<string, unknown> {
363
- const nested: Record<string, any> = {};
377
+ const nested: Record<string, unknown> = {};
364
378
  for (const [key, value] of Object.entries(flat)) {
379
+ if (FORBIDDEN_PROPERTIES.has(key.trim())) {
380
+ continue;
381
+ }
365
382
  if (key.includes('.')) {
366
383
  setNestedProperty(nested, key, value);
367
384
  } else {
@@ -0,0 +1,40 @@
1
+ import type React from 'react';
2
+ import { createContext, type ReactNode, useContext } from 'react';
3
+
4
+ export interface SliceOutletContextValue {
5
+ content?: ReactNode;
6
+ outlets?: Record<string, ReactNode>;
7
+ }
8
+
9
+ const SliceOutletContext = createContext<SliceOutletContextValue>({});
10
+
11
+ export interface SliceOutletProviderProps {
12
+ content?: ReactNode;
13
+ outlets?: Record<string, ReactNode>;
14
+ children: ReactNode;
15
+ }
16
+
17
+ export function SliceOutletProvider({ content, outlets = {}, children }: SliceOutletProviderProps) {
18
+ return <SliceOutletContext.Provider value={{ content, outlets }}>{children}</SliceOutletContext.Provider>;
19
+ }
20
+
21
+ export interface SliceOutletProps {
22
+ name?: string;
23
+ fallback?: ReactNode;
24
+ children?: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * <SliceOutlet /> renders nested content within Root or Domain Layouts,
29
+ * enabling parallel sub-panels and composable nested views.
30
+ */
31
+ export function SliceOutlet({ name, fallback = null, children }: SliceOutletProps): React.ReactElement | null {
32
+ const ctx = useContext(SliceOutletContext);
33
+ if (children) {
34
+ return <>{children}</>;
35
+ }
36
+ if (name && ctx.outlets) {
37
+ return <>{ctx.outlets[name] ?? fallback}</>;
38
+ }
39
+ return <>{ctx.content ?? fallback}</>;
40
+ }
@@ -0,0 +1,45 @@
1
+ import React, { type ComponentType, type ReactElement, useMemo } from 'react';
2
+ import { rpcCall } from '../core/rpc-client';
3
+ import { SynapseProvider } from './context';
4
+
5
+ export interface SubSliceProps<TProps extends Record<string, unknown> = Record<string, unknown>> {
6
+ domain: string;
7
+ name: string;
8
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic component props boundary
9
+ component?: ComponentType<any>;
10
+ props?: TProps;
11
+ actionPropNames?: string[];
12
+ children?: (injectedProps: Record<string, unknown>) => ReactElement;
13
+ }
14
+
15
+ /**
16
+ * <SubSlice /> dynamically embeds a sub-slice component into any layout or parent view,
17
+ * automatically wiring its transparent RPC action endpoint without violating Locality of Behavior (N = 1).
18
+ */
19
+ export function SubSlice<TProps extends Record<string, unknown> = Record<string, unknown>>({
20
+ domain,
21
+ name,
22
+ component: Component,
23
+ props = {} as TProps,
24
+ actionPropNames = ['action', 'onSubmitAction'],
25
+ children
26
+ }: SubSliceProps<TProps>): ReactElement {
27
+ const rpcPath = `/_synapse/rpc/${domain}/${name}`;
28
+
29
+ const boundActions = useMemo(() => {
30
+ const actions: Record<string, (payload: unknown) => Promise<unknown>> = {};
31
+ for (const actionName of actionPropNames) {
32
+ actions[actionName] = (payload: unknown) => rpcCall(rpcPath, payload);
33
+ }
34
+ return actions;
35
+ }, [rpcPath, actionPropNames]);
36
+
37
+ const mergedProps = {
38
+ ...props,
39
+ ...boundActions
40
+ };
41
+
42
+ const content = children ? children(mergedProps) : Component ? React.createElement(Component, mergedProps) : null;
43
+
44
+ return <SynapseProvider props={mergedProps}>{content}</SynapseProvider>;
45
+ }
@@ -0,0 +1,146 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ export interface UseWebSocketOptions<TServerMsg = unknown> {
4
+ enabled?: boolean;
5
+ reconnect?: boolean;
6
+ reconnectIntervalMs?: number;
7
+ onOpen?: () => void;
8
+ onMessage?: (message: TServerMsg) => void;
9
+ onClose?: (event: CloseEvent) => void;
10
+ onError?: (event: Event) => void;
11
+ }
12
+
13
+ export interface UseWebSocketResult<TServerMsg = unknown, TClientMsg = unknown> {
14
+ isConnected: boolean;
15
+ lastMessage: TServerMsg | null;
16
+ send: (data: TClientMsg | string) => void;
17
+ close: () => void;
18
+ error: Error | null;
19
+ }
20
+
21
+ /**
22
+ * Hook for bidirectional WebSocket communication with SynapseJS slices.
23
+ */
24
+ export function useWebSocket<TServerMsg = unknown, TClientMsg = unknown>(
25
+ pathOrSlice: string,
26
+ options: UseWebSocketOptions<TServerMsg> = {}
27
+ ): UseWebSocketResult<TServerMsg, TClientMsg> {
28
+ const { enabled = true, reconnect = true, reconnectIntervalMs = 3000 } = options;
29
+
30
+ const [isConnected, setIsConnected] = useState<boolean>(false);
31
+ const [lastMessage, setLastMessage] = useState<TServerMsg | null>(null);
32
+ const [error, setError] = useState<Error | null>(null);
33
+
34
+ const socketRef = useRef<WebSocket | null>(null);
35
+ const optionsRef = useRef(options);
36
+ optionsRef.current = options;
37
+
38
+ const resolveUrl = useCallback((path: string): string => {
39
+ if (typeof window === 'undefined') return '';
40
+ if (path.startsWith('ws://') || path.startsWith('wss://')) return path;
41
+
42
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
43
+ const host = window.location.host;
44
+
45
+ if (path.startsWith('/')) {
46
+ return `${protocol}//${host}${path}`;
47
+ }
48
+ // Formato <domain>/<name>
49
+ return `${protocol}//${host}/_synapse/ws/${path}`;
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ if (!enabled || typeof window === 'undefined') return;
54
+
55
+ let isMounted = true;
56
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
57
+
58
+ const connect = () => {
59
+ try {
60
+ const url = resolveUrl(pathOrSlice);
61
+ if (!url) return;
62
+
63
+ const ws = new WebSocket(url);
64
+ socketRef.current = ws;
65
+
66
+ ws.onopen = () => {
67
+ if (!isMounted) return;
68
+ setIsConnected(true);
69
+ setError(null);
70
+ optionsRef.current.onOpen?.();
71
+ };
72
+
73
+ ws.onmessage = (event) => {
74
+ if (!isMounted) return;
75
+ try {
76
+ const data = JSON.parse(event.data) as TServerMsg;
77
+ setLastMessage(data);
78
+ optionsRef.current.onMessage?.(data);
79
+ } catch {
80
+ const raw = event.data as unknown as TServerMsg;
81
+ setLastMessage(raw);
82
+ optionsRef.current.onMessage?.(raw);
83
+ }
84
+ };
85
+
86
+ ws.onerror = (e) => {
87
+ if (!isMounted) return;
88
+ const err = new Error(`Erro na conexão WebSocket para "${pathOrSlice}"`);
89
+ setError(err);
90
+ optionsRef.current.onError?.(e);
91
+ };
92
+
93
+ ws.onclose = (e) => {
94
+ if (!isMounted) return;
95
+ setIsConnected(false);
96
+ optionsRef.current.onClose?.(e);
97
+
98
+ if (reconnect && isMounted && !e.wasClean) {
99
+ reconnectTimeout = setTimeout(connect, reconnectIntervalMs);
100
+ }
101
+ };
102
+ } catch (err) {
103
+ if (!isMounted) return;
104
+ setError(err instanceof Error ? err : new Error(String(err)));
105
+ }
106
+ };
107
+
108
+ connect();
109
+
110
+ return () => {
111
+ isMounted = false;
112
+ if (reconnectTimeout) clearTimeout(reconnectTimeout);
113
+ if (socketRef.current) {
114
+ socketRef.current.close();
115
+ socketRef.current = null;
116
+ }
117
+ setIsConnected(false);
118
+ };
119
+ }, [pathOrSlice, enabled, reconnect, reconnectIntervalMs, resolveUrl]);
120
+
121
+ const send = useCallback(
122
+ (data: TClientMsg | string) => {
123
+ if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
124
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
125
+ socketRef.current.send(payload);
126
+ } else {
127
+ console.warn(`[useWebSocket] Tentativa de envio em socket desconectado (${pathOrSlice})`);
128
+ }
129
+ },
130
+ [pathOrSlice]
131
+ );
132
+
133
+ const close = useCallback(() => {
134
+ if (socketRef.current) {
135
+ socketRef.current.close();
136
+ }
137
+ }, []);
138
+
139
+ return {
140
+ isConnected,
141
+ lastMessage,
142
+ send,
143
+ close,
144
+ error
145
+ };
146
+ }
package/src/client.ts CHANGED
@@ -53,6 +53,18 @@ export {
53
53
  type TranslateFn,
54
54
  type TranslationDictionary
55
55
  } from './client/i18n';
56
+ export {
57
+ SliceOutlet,
58
+ type SliceOutletProps,
59
+ SliceOutletProvider,
60
+ type SliceOutletProviderProps
61
+ } from './client/outlet';
62
+ export { SubSlice, type SubSliceProps } from './client/sub-slice';
63
+ export {
64
+ type UseWebSocketOptions,
65
+ type UseWebSocketResult,
66
+ useWebSocket
67
+ } from './client/use-websocket';
56
68
  export type { Result } from './core/machine-types';
57
69
  export { type RpcTransportError, rpcCall, rpcTransportFailure } from './core/rpc-client';
58
70
  export type { SessionContext } from './core/session-context';
@@ -61,11 +61,16 @@ export function clientManifestPath(baseDir: string): string {
61
61
  return path.join(baseDir, '.synapse', 'client', 'manifest.json');
62
62
  }
63
63
 
64
- export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBundle, BuildError>> {
64
+ export async function buildVendorBundle(
65
+ baseDir: string,
66
+ vendorPackages: string[] = []
67
+ ): Promise<Result<BuiltBundle, BuildError>> {
65
68
  const vendorEntryDir = path.join(baseDir, '.synapse/client-entry');
66
69
  fs.mkdirSync(vendorEntryDir, { recursive: true });
67
70
  const vendorEntryPath = path.join(vendorEntryDir, '_vendor.ts');
68
71
 
72
+ const extraExports = (vendorPackages || []).map((pkg) => `export * from '${pkg}';`).join('\n');
73
+
69
74
  const vendorEntryContent = [
70
75
  `import React from 'react';`,
71
76
  `import * as ReactDOM from 'react-dom';`,
@@ -73,6 +78,7 @@ export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBu
73
78
  `import * as SynapseClient from 'synapsejs/client';`,
74
79
  `import * as jsxRuntime from 'react/jsx-runtime';`,
75
80
  `import * as jsxDevRuntime from 'react/jsx-dev-runtime';`,
81
+ ...(extraExports ? [extraExports] : []),
76
82
  ``,
77
83
  `export default React;`,
78
84
  `export * from 'react';`,
@@ -209,7 +215,8 @@ export function componentExportOf(module: Record<string, unknown>): string | und
209
215
 
210
216
  export async function buildClientBundle(
211
217
  target: BundleTarget,
212
- baseDir: string
218
+ baseDir: string,
219
+ options?: { vendorPackages?: string[] }
213
220
  ): Promise<Result<BuiltBundle, BuildError>> {
214
221
  // Uma fatia que não carrega não pode derrubar o build inteiro: ela vira FAIL com o motivo.
215
222
  let sliceModule: Record<string, unknown>;
@@ -311,7 +318,7 @@ export async function buildClientBundle(
311
318
  if (vendorSplit) {
312
319
  const vendorFile = path.join(baseDir, '.synapse/client', VENDOR_BUNDLE_NAME);
313
320
  if (!fs.existsSync(vendorFile)) {
314
- await buildVendorBundle(baseDir);
321
+ await buildVendorBundle(baseDir, options?.vendorPackages);
315
322
  }
316
323
  }
317
324
 
@@ -334,7 +341,8 @@ export async function buildClientBundle(
334
341
  'react-dom',
335
342
  'react-dom/client',
336
343
  'synapsejs/client',
337
- '@ismaelsoilet/synapsejs/client'
344
+ '@ismaelsoilet/synapsejs/client',
345
+ ...(options?.vendorPackages || [])
338
346
  ]
339
347
  : [],
340
348
  define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' }
@@ -421,7 +429,8 @@ export interface BuildReportEntry {
421
429
  * component and fails to produce a bundle is a broken build.
422
430
  */
423
431
  export async function buildAllClientBundles(
424
- appRoot: string
432
+ appRoot: string,
433
+ options?: { vendorPackages?: string[] }
425
434
  ): Promise<Result<{ entries: BuildReportEntry[]; manifestFile: string }, SlicesDirError>> {
426
435
  const resolution = resolveSlicesDir(appRoot);
427
436
 
@@ -433,7 +442,7 @@ export async function buildAllClientBundles(
433
442
  const entries: BuildReportEntry[] = [];
434
443
  const built: BuiltBundle[] = [];
435
444
 
436
- const vendorRes = await buildVendorBundle(appRoot);
445
+ const vendorRes = await buildVendorBundle(appRoot, options?.vendorPackages);
437
446
  if (vendorRes.ok) {
438
447
  built.push(vendorRes.value);
439
448
  entries.push({ slice: '_vendor', status: 'PASS', url: vendorRes.value.url, bytes: vendorRes.value.bytes });
@@ -446,7 +455,8 @@ export async function buildAllClientBundles(
446
455
 
447
456
  const bundle = await buildClientBundle(
448
457
  { key, name, domain, filePath: file, rpcPath: `/_synapse/rpc/${domain}/${name}` },
449
- appRoot
458
+ appRoot,
459
+ options
450
460
  );
451
461
 
452
462
  if (bundle.ok) {