@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.
- package/README.md +15 -8
- package/bin/synapse.ts +70 -3
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/migration-runner.ts +134 -9
- package/src/compiler/scaffolder.ts +62 -0
- package/src/compiler/schema-dag.ts +18 -11
- package/src/compiler/slice-splitter.ts +29 -4
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +56 -0
- package/src/core/action-definition.ts +89 -0
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +37 -1
- package/src/core/index.ts +3 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +27 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +7 -1
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +1 -5
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +8 -1
- package/src/mcp/server.ts +63 -1
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +180 -0
- package/src/runtime/queue-engine.ts +55 -21
- package/src/runtime/rate-limiter.ts +14 -0
- package/src/runtime/server.ts +572 -139
- package/src/version.ts +4 -0
- 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,
|
|
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
|
|
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:**
|
|
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
|
|
130
|
-
|
|
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
|
-
|
|
190
|
+
SliceOutlet, SliceOutletProvider, SubSlice,
|
|
191
|
+
SynapseProvider, useAction, useLoaderData, useSubscription, useWebSocket, useSession, useSynapseContext,
|
|
186
192
|
createTranslator,
|
|
187
193
|
|
|
188
|
-
// Realtime
|
|
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.
|
|
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:
|
|
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
|
@@ -336,32 +336,49 @@ export interface DataFormProps {
|
|
|
336
336
|
className?: string;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
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,
|
|
361
|
+
export function getNestedProperty(obj: Record<string, unknown>, path: string): unknown {
|
|
353
362
|
const parts = path.split('.');
|
|
354
|
-
|
|
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)
|
|
357
|
-
|
|
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,
|
|
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(
|
|
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) {
|