@ismaelsoilet/synapsejs 1.2.0 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ismaelsoilet/synapsejs",
3
- "version": "1.2.0",
3
+ "version": "1.4.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",
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import type { Result } from '../core/machine-types';
3
+ import { useSynapseContext } from './context';
3
4
 
4
5
  export interface UseSubscriptionOptions {
5
6
  enabled?: boolean;
@@ -86,17 +87,33 @@ export interface UseActionResult<TPayload, TData, TError> {
86
87
  reset: () => void;
87
88
  }
88
89
 
90
+ export interface UseActionOptions<TData, TError> {
91
+ onSuccess?: (data: TData) => void;
92
+ onError?: (error: TError) => void;
93
+ }
94
+
89
95
  /**
90
96
  * Hook for executing server actions from client UI with reactive state management.
97
+ * If actionFn is not provided, it automatically resolves the slice's action from SynapseContext.
91
98
  */
92
99
  export function useAction<TPayload = unknown, TData = unknown, TError = string>(
93
- actionFn?: (payload: TPayload) => Promise<Result<TData, TError>>
100
+ actionFn?: ((payload: TPayload) => Promise<Result<TData, TError>>) | null,
101
+ options?: UseActionOptions<TData, TError>
94
102
  ): UseActionResult<TPayload, TData, TError> {
95
103
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
96
104
  const [data, setData] = useState<TData | null>(null);
97
105
  const [error, setError] = useState<TError | null>(null);
98
106
  const [isSuccess, setIsSuccess] = useState<boolean>(false);
99
107
 
108
+ const ctx = useSynapseContext();
109
+ const resolvedAction =
110
+ actionFn ??
111
+ (ctx.props?.action as ((payload: TPayload) => Promise<Result<TData, TError>>) | undefined) ??
112
+ (ctx.props?.onSubmitAction as ((payload: TPayload) => Promise<Result<TData, TError>>) | undefined);
113
+
114
+ const optionsRef = useRef(options);
115
+ optionsRef.current = options;
116
+
100
117
  const reset = useCallback(() => {
101
118
  setIsSubmitting(false);
102
119
  setData(null);
@@ -106,9 +123,10 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
106
123
 
107
124
  const execute = useCallback(
108
125
  async (payload: TPayload): Promise<Result<TData, TError>> => {
109
- if (!actionFn) {
126
+ if (!resolvedAction) {
110
127
  const errVal = 'NO_ACTION_FUNCTION' as unknown as TError;
111
128
  setError(errVal);
129
+ optionsRef.current?.onError?.(errVal);
112
130
  return { ok: false, error: errVal };
113
131
  }
114
132
 
@@ -117,26 +135,29 @@ export function useAction<TPayload = unknown, TData = unknown, TError = string>(
117
135
  setIsSuccess(false);
118
136
 
119
137
  try {
120
- const result = await actionFn(payload);
138
+ const result = await resolvedAction(payload);
121
139
  if (result.ok) {
122
140
  setData(result.value);
123
141
  setIsSuccess(true);
124
142
  setError(null);
143
+ optionsRef.current?.onSuccess?.(result.value);
125
144
  } else {
126
145
  setError(result.error);
127
146
  setIsSuccess(false);
147
+ optionsRef.current?.onError?.(result.error);
128
148
  }
129
149
  return result;
130
150
  } catch (err: unknown) {
131
151
  const failure = (err instanceof Error ? err.message : String(err)) as unknown as TError;
132
152
  setError(failure);
133
153
  setIsSuccess(false);
154
+ optionsRef.current?.onError?.(failure);
134
155
  return { ok: false, error: failure };
135
156
  } finally {
136
157
  setIsSubmitting(false);
137
158
  }
138
159
  },
139
- [actionFn]
160
+ [resolvedAction]
140
161
  );
141
162
 
142
163
  return {
package/src/client.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  } from './client/context';
42
42
  // Reactive Action & Realtime Subscription Hooks
43
43
  export {
44
+ type UseActionOptions,
44
45
  type UseActionResult,
45
46
  type UseSubscriptionOptions,
46
47
  type UseSubscriptionResult,
@@ -71,13 +71,77 @@ export async function buildVendorBundle(baseDir: string): Promise<Result<BuiltBu
71
71
  `import * as ReactDOM from 'react-dom';`,
72
72
  `import * as ReactDOMClient from 'react-dom/client';`,
73
73
  `import * as SynapseClient from 'synapsejs/client';`,
74
+ `import * as jsxRuntime from 'react/jsx-runtime';`,
75
+ `import * as jsxDevRuntime from 'react/jsx-dev-runtime';`,
74
76
  ``,
75
77
  `export default React;`,
76
78
  `export * from 'react';`,
77
79
  `export * from 'react-dom';`,
78
80
  `export * from 'react-dom/client';`,
79
81
  `export * from 'synapsejs/client';`,
80
- `export { ReactDOM, ReactDOMClient, SynapseClient };`,
82
+ `export * from 'react/jsx-runtime';`,
83
+ `export * from 'react/jsx-dev-runtime';`,
84
+ `const { hydrateRoot, createRoot } = ReactDOMClient;`,
85
+ `const { jsx, jsxs } = (jsxRuntime as unknown as Record<string, unknown>);`,
86
+ `const { jsxDEV } = (jsxDevRuntime as unknown as Record<string, unknown>);`,
87
+ `const {`,
88
+ ` useState,`,
89
+ ` useEffect,`,
90
+ ` useCallback,`,
91
+ ` useMemo,`,
92
+ ` useRef,`,
93
+ ` useContext,`,
94
+ ` useReducer,`,
95
+ ` useId,`,
96
+ ` useLayoutEffect,`,
97
+ ` useTransition,`,
98
+ ` useDeferredValue,`,
99
+ ` createContext,`,
100
+ ` createElement,`,
101
+ ` cloneElement,`,
102
+ ` isValidElement,`,
103
+ ` Fragment,`,
104
+ ` Component,`,
105
+ ` PureComponent,`,
106
+ ` memo,`,
107
+ ` forwardRef,`,
108
+ ` Children,`,
109
+ ` startTransition`,
110
+ `} = (React as unknown as Record<string, unknown>);`,
111
+ `export {`,
112
+ ` ReactDOM,`,
113
+ ` ReactDOMClient,`,
114
+ ` SynapseClient,`,
115
+ ` jsxRuntime,`,
116
+ ` jsxDevRuntime,`,
117
+ ` hydrateRoot,`,
118
+ ` createRoot,`,
119
+ ` jsx,`,
120
+ ` jsxs,`,
121
+ ` jsxDEV,`,
122
+ ` useState,`,
123
+ ` useEffect,`,
124
+ ` useCallback,`,
125
+ ` useMemo,`,
126
+ ` useRef,`,
127
+ ` useContext,`,
128
+ ` useReducer,`,
129
+ ` useId,`,
130
+ ` useLayoutEffect,`,
131
+ ` useTransition,`,
132
+ ` useDeferredValue,`,
133
+ ` createContext,`,
134
+ ` createElement,`,
135
+ ` cloneElement,`,
136
+ ` isValidElement,`,
137
+ ` Fragment,`,
138
+ ` Component,`,
139
+ ` PureComponent,`,
140
+ ` memo,`,
141
+ ` forwardRef,`,
142
+ ` Children,`,
143
+ ` startTransition`,
144
+ `};`,
81
145
  ``
82
146
  ].join('\n');
83
147
 
@@ -194,12 +258,51 @@ export async function buildClientBundle(
194
258
  const entryPath = path.join(entryDir, `${target.domain}-${target.name}.tsx`);
195
259
  const modulePath = path.relative(entryDir, path.join(outDir, clientArtifact.fileName)).split(path.sep).join('/');
196
260
 
261
+ // Detect optional root and domain layouts for isomorphic hydration
262
+ let rootLayoutPath: string | undefined;
263
+ let domainLayoutPath: string | undefined;
264
+
265
+ let currentDir = path.dirname(target.filePath);
266
+ let slicesDir: string | null = null;
267
+ while (currentDir && currentDir !== baseDir && currentDir !== path.dirname(currentDir)) {
268
+ if (path.basename(currentDir) === 'slices') {
269
+ slicesDir = currentDir;
270
+ break;
271
+ }
272
+ currentDir = path.dirname(currentDir);
273
+ }
274
+
275
+ if (slicesDir) {
276
+ const rootCandidates = [path.join(slicesDir, '_layout.tsx'), path.join(slicesDir, 'layout.tsx')];
277
+ for (const cand of rootCandidates) {
278
+ if (fs.existsSync(cand)) {
279
+ const rel = path.relative(entryDir, cand).split(path.sep).join('/');
280
+ rootLayoutPath = rel.startsWith('.') ? rel : `./${rel}`;
281
+ break;
282
+ }
283
+ }
284
+
285
+ const domainDir = path.dirname(target.filePath);
286
+ if (domainDir !== slicesDir) {
287
+ const domainCandidates = [path.join(domainDir, '_layout.tsx'), path.join(domainDir, 'layout.tsx')];
288
+ for (const cand of domainCandidates) {
289
+ if (fs.existsSync(cand)) {
290
+ const rel = path.relative(entryDir, cand).split(path.sep).join('/');
291
+ domainLayoutPath = rel.startsWith('.') ? rel : `./${rel}`;
292
+ break;
293
+ }
294
+ }
295
+ }
296
+ }
297
+
197
298
  fs.writeFileSync(
198
299
  entryPath,
199
300
  clientEntrySource({
200
301
  componentName: componentExport,
201
302
  clientModulePath: `./${modulePath}`,
202
- rpcPath: target.rpcPath
303
+ rpcPath: target.rpcPath,
304
+ rootLayoutPath,
305
+ domainLayoutPath
203
306
  }),
204
307
  'utf-8'
205
308
  );
@@ -224,7 +327,15 @@ export async function buildClientBundle(
224
327
  naming: '[name].js',
225
328
  minify: production,
226
329
  external: vendorSplit
227
- ? ['react', 'react-dom', 'react-dom/client', 'synapsejs/client', '@ismaelsoilet/synapsejs/client']
330
+ ? [
331
+ 'react',
332
+ 'react/jsx-runtime',
333
+ 'react/jsx-dev-runtime',
334
+ 'react-dom',
335
+ 'react-dom/client',
336
+ 'synapsejs/client',
337
+ '@ismaelsoilet/synapsejs/client'
338
+ ]
228
339
  : [],
229
340
  define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"' }
230
341
  });
@@ -61,7 +61,7 @@ import {
61
61
  // ============================================================================
62
62
  export const ${inputSchemaName} = Type.Object({
63
63
  ${tbProps}
64
- });
64
+ }, { additionalProperties: true });
65
65
  export type ${inputTypeName} = Static<typeof ${inputSchemaName}>;
66
66
 
67
67
  // DDL Schema Declarativo da Fatia (Auto-Migrado pelo Synapse)
@@ -211,7 +211,7 @@ export const ${inputSchemaName} = Type.Object({
211
211
  name: Type.String({ minLength: 2, maxLength: 100 }),
212
212
  email: Type.String({ format: 'email' }),
213
213
  metadata: Type.Optional(Type.String())
214
- });
214
+ }, { additionalProperties: true });
215
215
  export type ${inputTypeName} = Static<typeof ${inputSchemaName}>;
216
216
 
217
217
  // DDL Schema Declarativo da Fatia (Auto-Migrado pelo Synapse)
@@ -25,26 +25,30 @@ export function parseTableDependencies(ddl: string): { created: string[]; refere
25
25
  const created: string[] = [];
26
26
  const referenced: string[] = [];
27
27
 
28
- // Match CREATE TABLE [IF NOT EXISTS] <name>
29
- const createRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
30
- let match: RegExpExecArray | null = createRegex.exec(ddl);
28
+ // Strip SQL comments to avoid false-positive dependencies
29
+ const cleanDdl = ddl.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
30
+
31
+ // Match CREATE TABLE [IF NOT EXISTS] [schema.]<name>
32
+ const createRegex =
33
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:["'`]?[a-zA-Z0-9_]+["'`]?\.)?["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
34
+ let match: RegExpExecArray | null = createRegex.exec(cleanDdl);
31
35
  while (match !== null) {
32
36
  const tableName = match[1].toLowerCase();
33
37
  if (!created.includes(tableName)) {
34
38
  created.push(tableName);
35
39
  }
36
- match = createRegex.exec(ddl);
40
+ match = createRegex.exec(cleanDdl);
37
41
  }
38
42
 
39
- // Match REFERENCES <name>
40
- const refRegex = /REFERENCES\s+["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
41
- let refMatch: RegExpExecArray | null = refRegex.exec(ddl);
43
+ // Match REFERENCES [schema.]<name>[(columns)]
44
+ const refRegex = /REFERENCES\s+(?:["'`]?[a-zA-Z0-9_]+["'`]?\.)?["'`]?([a-zA-Z0-9_]+)["'`]?/gi;
45
+ let refMatch: RegExpExecArray | null = refRegex.exec(cleanDdl);
42
46
  while (refMatch !== null) {
43
47
  const tableName = refMatch[1].toLowerCase();
44
48
  if (!created.includes(tableName) && !referenced.includes(tableName)) {
45
49
  referenced.push(tableName);
46
50
  }
47
- refMatch = refRegex.exec(ddl);
51
+ refMatch = refRegex.exec(cleanDdl);
48
52
  }
49
53
 
50
54
  return { created, referenced };
@@ -75,11 +75,19 @@ const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
75
75
  { label: 'UPDATE statement', pattern: /\bUPDATE\s+\w+\s+SET\b/i },
76
76
  { label: 'DELETE statement', pattern: /\bDELETE\s+FROM\b/i },
77
77
  { label: 'CREATE TABLE statement', pattern: /\bCREATE\s+TABLE\b/i },
78
+ { label: 'ALTER TABLE statement', pattern: /\bALTER\s+TABLE\b/i },
79
+ { label: 'DROP TABLE statement', pattern: /\bDROP\s+TABLE\b/i },
78
80
  { label: 'database call', pattern: /\bdb\s*\.\s*(?:query|findMany|findOne|insert|update|delete|sql|sqlOne)\b/ },
79
81
  { label: 'sliceSchema symbol', pattern: /\bsliceSchema\b/ },
80
82
  { label: 'test runner import', pattern: /from\s+['"]bun:test['"]/ },
81
- { label: 'process.env access', pattern: /\bprocess\.env\b/ },
82
- { label: 'Bun global', pattern: /\bBun\./ }
83
+ { label: 'process.env access', pattern: /\bprocess\s*(?:\.\s*env|\[\s*['"]env['"]\s*\])/ },
84
+ { label: 'process destructuring', pattern: /\b(?:const|let|var)\s*\{[^}]*\benv\b[^}]*\}\s*=\s*process\b/ },
85
+ { label: 'Bun global', pattern: /\bBun\./ },
86
+ {
87
+ label: 'node server module import',
88
+ pattern: /from\s+['"](?:node:)?(?:fs|child_process|cluster|dgram|dns|net|tls|v8|vm)['"]/
89
+ },
90
+ { label: 'dynamic code evaluation', pattern: /\b(?:eval|new\s+Function)\s*\(/ }
83
91
  ];
84
92
 
85
93
  interface DeclEntry {
@@ -88,7 +88,7 @@ export const ${names.schemaName} = Type.Object({
88
88
  query: Type.Optional(Type.String({ maxLength: 60 })),
89
89
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
90
90
  offset: Type.Optional(Type.Integer({ minimum: 0 }))
91
- });
91
+ }, { additionalProperties: true });
92
92
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
93
93
 
94
94
  // ============================================================================
@@ -236,7 +236,7 @@ export const ${names.schemaName} = Type.Object({
236
236
  query: Type.Optional(Type.String({ maxLength: 60 })),
237
237
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
238
238
  offset: Type.Optional(Type.Integer({ minimum: 0 }))
239
- });
239
+ }, { additionalProperties: true });
240
240
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
241
241
 
242
242
  // A tabela pertence a outra fatia desta mesma feature.
@@ -450,7 +450,7 @@ function updateTemplate(names: TemplateNames): string {
450
450
  export const ${names.schemaName} = Type.Object({
451
451
  id: Type.String({ minLength: 1 }),
452
452
  name: Type.Optional(Type.String({ minLength: 2, maxLength: 120 }))
453
- });
453
+ }, { additionalProperties: true });
454
454
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
455
455
 
456
456
  // ============================================================================
@@ -657,7 +657,7 @@ function deleteTemplate(names: TemplateNames): string {
657
657
  // ============================================================================
658
658
  export const ${names.schemaName} = Type.Object({
659
659
  id: Type.String({ minLength: 1 })
660
- });
660
+ }, { additionalProperties: true });
661
661
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
662
662
 
663
663
  // ============================================================================
@@ -806,7 +806,7 @@ import { storeSession } from 'synapsejs/client';
806
806
  export const ${names.schemaName} = Type.Object({
807
807
  email: Type.String({ minLength: 3, maxLength: 200 }),
808
808
  password: Type.String({ minLength: 8, maxLength: 200 })
809
- });
809
+ }, { additionalProperties: true });
810
810
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
811
811
 
812
812
  // ============================================================================
@@ -1075,7 +1075,7 @@ import {
1075
1075
  export const ${names.schemaName} = Type.Object({
1076
1076
  code: Type.String({ minLength: 1, maxLength: 500 }),
1077
1077
  state: Type.Optional(Type.String({ maxLength: 500 }))
1078
- });
1078
+ }, { additionalProperties: true });
1079
1079
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
1080
1080
 
1081
1081
  // ============================================================================
@@ -1353,7 +1353,7 @@ import { signSessionToken } from 'synapsejs';
1353
1353
  export const ${names.schemaName} = Type.Object({
1354
1354
  userId: Type.String({ minLength: 1 }),
1355
1355
  code: Type.String({ minLength: 6, maxLength: 6 })
1356
- });
1356
+ }, { additionalProperties: true });
1357
1357
  export type ${names.inputName} = Static<typeof ${names.schemaName}>;
1358
1358
 
1359
1359
  // ============================================================================
@@ -0,0 +1,89 @@
1
+ /**
2
+ * SynapseJS - Declarative Action Definition Primitive
3
+ *
4
+ * Implements "Secure by Default" (Fail-Closed) semantics and wire-speed automatic
5
+ * TypeBox schema validation. Prevents inadvertent public exposure of RPC endpoints
6
+ * and eliminates repetitive manual `Value.Check` and `requireAuth` boilerplate.
7
+ */
8
+
9
+ import type { Static, TSchema } from '@sinclair/typebox';
10
+ import { Value } from '@sinclair/typebox/value';
11
+ import type { DatabaseClient } from './database-client';
12
+ import { Err, type Result } from './machine-types';
13
+ import { requireAuth, type SessionContext } from './session-context';
14
+
15
+ export type ActionAuthRule = 'public' | string[];
16
+
17
+ export interface ActionDefinitionConfig<TSchemaInput extends TSchema | undefined, TOutput> {
18
+ /**
19
+ * Optional TypeBox schema for input payload validation.
20
+ * If supplied, incoming requests are automatically validated before the handler runs.
21
+ */
22
+ input?: TSchemaInput;
23
+
24
+ /**
25
+ * Authorization rules for this action.
26
+ * - Omitted / undefined: Requires an authenticated user session (Fail-Closed by default).
27
+ * - Array of strings (e.g. ['admin', 'manager']): Requires the session to possess at least one of these roles.
28
+ * - 'public': Explicitly marks the action as accessible to unauthenticated callers.
29
+ */
30
+ auth?: ActionAuthRule;
31
+
32
+ /**
33
+ * Handler function executing business logic.
34
+ * Receives strongly-typed validated input, database client and authenticated session context.
35
+ */
36
+ handler: (ctx: {
37
+ input: TSchemaInput extends TSchema ? Static<TSchemaInput> : unknown;
38
+ db?: DatabaseClient;
39
+ session?: SessionContext;
40
+ }) => Promise<TOutput> | TOutput;
41
+ }
42
+
43
+ export type ActionHandlerFn<TSchemaInput extends TSchema | undefined, TOutput> = {
44
+ (
45
+ payload: unknown,
46
+ db?: DatabaseClient,
47
+ session?: SessionContext
48
+ ): Promise<TOutput | Result<never, 'INVALID_SCHEMA' | 'UNAUTHORIZED' | 'FORBIDDEN'>>;
49
+ readonly __isSynapseAction: true;
50
+ readonly config: ActionDefinitionConfig<TSchemaInput, TOutput>;
51
+ };
52
+
53
+ export function defineAction<TSchemaInput extends TSchema | undefined, TOutput>(
54
+ config: ActionDefinitionConfig<TSchemaInput, TOutput>
55
+ ): ActionHandlerFn<TSchemaInput, TOutput> {
56
+ const actionFn = async (
57
+ payload: unknown,
58
+ db?: DatabaseClient,
59
+ session?: SessionContext
60
+ ): Promise<TOutput | Result<never, 'INVALID_SCHEMA' | 'UNAUTHORIZED' | 'FORBIDDEN'>> => {
61
+ // 1. Fail-Closed Authentication Guard
62
+ if (config.auth !== 'public') {
63
+ const requiredRoles = Array.isArray(config.auth) ? config.auth : undefined;
64
+ const auth = requireAuth(session, requiredRoles);
65
+ if (!auth.ok) {
66
+ return Err(auth.error);
67
+ }
68
+ }
69
+
70
+ // 2. Automatic Wire-Speed Schema Validation
71
+ if (config.input) {
72
+ if (!Value.Check(config.input, payload)) {
73
+ return Err('INVALID_SCHEMA');
74
+ }
75
+ }
76
+
77
+ // 3. Typed Handler Invocation
78
+ return config.handler({
79
+ input: payload as TSchemaInput extends TSchema ? Static<TSchemaInput> : unknown,
80
+ db,
81
+ session
82
+ });
83
+ };
84
+
85
+ Object.defineProperty(actionFn, '__isSynapseAction', { value: true, writable: false });
86
+ Object.defineProperty(actionFn, 'config', { value: config, writable: false });
87
+
88
+ return actionFn as ActionHandlerFn<TSchemaInput, TOutput>;
89
+ }
package/src/core/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './action-context';
2
+ export * from './action-definition';
2
3
  export * from './config';
3
4
  export * from './database-client';
4
5
  export * from './database-factory';
@@ -12,3 +13,4 @@ export * from './session-context';
12
13
  export * from './session-token';
13
14
  export * from './sqlite-client';
14
15
  export * from './storage';
16
+ export * from './validation';
@@ -0,0 +1,57 @@
1
+ /**
2
+ * SynapseJS - Schema Validation Utilities
3
+ *
4
+ * Provides structured, human-readable validation for TypeBox contracts.
5
+ * Rather than returning an opaque 'INVALID_SCHEMA', it extracts the exact
6
+ * paths and failure reasons to provide actionable machine and developer diagnostics.
7
+ */
8
+
9
+ import type { Static, TSchema } from '@sinclair/typebox';
10
+ import { Value } from '@sinclair/typebox/value';
11
+ import { Err, Ok, type Result } from './machine-types';
12
+
13
+ export interface ValidationErrorDetail {
14
+ path: string;
15
+ message: string;
16
+ value?: unknown;
17
+ }
18
+
19
+ export interface ValidationFailure {
20
+ code: 'INVALID_SCHEMA';
21
+ message: string;
22
+ errors: ValidationErrorDetail[];
23
+ }
24
+
25
+ /**
26
+ * Validates a payload against a TypeBox schema with detailed error reporting.
27
+ * Returns Ok(validatedValue) or Err with formatted diagnosis.
28
+ */
29
+ export function validateSchema<T extends TSchema>(schema: T, payload: unknown): Result<Static<T>, string> {
30
+ if (Value.Check(schema, payload)) {
31
+ return Ok(payload as Static<T>);
32
+ }
33
+
34
+ const errors: ValidationErrorDetail[] = [];
35
+ try {
36
+ for (const err of Value.Errors(schema, payload)) {
37
+ errors.push({
38
+ path: err.path || '/',
39
+ message: err.message,
40
+ value: err.value
41
+ });
42
+ }
43
+ } catch {
44
+ // Se a extração de erros falhar, mantém fallback seguro
45
+ }
46
+
47
+ if (errors.length === 0) {
48
+ return Err('INVALID_SCHEMA: Falha na validação do contrato de entrada.');
49
+ }
50
+
51
+ const formattedDetails = errors
52
+ .slice(0, 3)
53
+ .map((e) => `campo "${e.path}": ${e.message}`)
54
+ .join('; ');
55
+
56
+ return Err(`INVALID_SCHEMA: ${formattedDetails}`);
57
+ }
@@ -17,6 +17,8 @@ export interface ClientEntryOptions {
17
17
  rpcPath: string;
18
18
  rootId?: string;
19
19
  actionPropNames?: string[];
20
+ rootLayoutPath?: string;
21
+ domainLayoutPath?: string;
20
22
  }
21
23
 
22
24
  export const CLIENT_ROOT_ID = 'synapse-root';
@@ -26,14 +28,25 @@ export function clientEntrySource(options: ClientEntryOptions): string {
26
28
  const rootId = options.rootId ?? CLIENT_ROOT_ID;
27
29
  const propNames = options.actionPropNames ?? ['onSubmitAction', 'action'];
28
30
  const actionWires = propNames
29
- .map((name) => ` ${name}: (payload: unknown) => rpcCall(${JSON.stringify(options.rpcPath)}, payload)`)
31
+ .map((name) => ` ${name}: (payload: unknown) => rpcCall(${JSON.stringify(options.rpcPath)}, payload)`)
30
32
  .join(',\n');
31
33
 
32
- return [
34
+ const layoutImports: string[] = [];
35
+ if (options.rootLayoutPath) {
36
+ layoutImports.push(`import * as RootLayoutModule from ${JSON.stringify(options.rootLayoutPath)};`);
37
+ }
38
+ if (options.domainLayoutPath) {
39
+ layoutImports.push(`import * as DomainLayoutModule from ${JSON.stringify(options.domainLayoutPath)};`);
40
+ }
41
+
42
+ const hasLayouts = Boolean(options.rootLayoutPath || options.domainLayoutPath);
43
+
44
+ const lines = [
33
45
  `// [SYNAPSE-JS GENERATED CLIENT ENTRY] hydrates the slice in the browser`,
34
46
  `import React from 'react';`,
35
47
  `import { hydrateRoot } from 'react-dom/client';`,
36
- `import { rpcCall } from 'synapsejs/client';`,
48
+ `import { rpcCall, SynapseProvider } from 'synapsejs/client';`,
49
+ ...layoutImports,
37
50
  `import { ${options.componentName} } from ${JSON.stringify(options.clientModulePath)};`,
38
51
  ``,
39
52
  `export function hydrate() {`,
@@ -41,19 +54,49 @@ export function clientEntrySource(options: ClientEntryOptions): string {
41
54
  ` const props = (scope[${JSON.stringify(CLIENT_PROPS_GLOBAL)}] ?? {}) as Record<string, unknown>;`,
42
55
  ` const root = document.getElementById(${JSON.stringify(rootId)});`,
43
56
  ` if (root) {`,
44
- ` hydrateRoot(`,
45
- ` root,`,
46
- ` React.createElement(${options.componentName}, {`,
47
- ` ...props,`,
57
+ ` const sliceElement = React.createElement(${options.componentName}, {`,
58
+ ` ...props,`,
48
59
  `${actionWires}`,
49
- ` })`,
50
- ` );`,
51
- ` }`,
52
- `}`,
53
- ``,
54
- `hydrate();`,
55
- ``
56
- ].join('\n');
60
+ ` });`
61
+ ];
62
+
63
+ if (hasLayouts) {
64
+ if (options.domainLayoutPath) {
65
+ lines.push(
66
+ ` const DomainLayout = (DomainLayoutModule as any).default || (DomainLayoutModule as any).Layout || null;`
67
+ );
68
+ }
69
+ if (options.rootLayoutPath) {
70
+ lines.push(
71
+ ` const RootLayout = (RootLayoutModule as any).default || (RootLayoutModule as any).Layout || null;`
72
+ );
73
+ }
74
+ let treeExpr = 'sliceElement';
75
+ if (options.domainLayoutPath) {
76
+ treeExpr = `(typeof DomainLayout === 'function' ? React.createElement(DomainLayout, { ...props }, ${treeExpr}) : ${treeExpr})`;
77
+ }
78
+ if (options.rootLayoutPath) {
79
+ treeExpr = `(typeof RootLayout === 'function' ? React.createElement(RootLayout, { ...props }, ${treeExpr}) : ${treeExpr})`;
80
+ }
81
+ lines.push(` const treeWithLayout = ${treeExpr};`);
82
+ lines.push(` hydrateRoot(`);
83
+ lines.push(` root,`);
84
+ lines.push(` React.createElement(SynapseProvider, { props, session: props.session as any }, treeWithLayout)`);
85
+ lines.push(` );`);
86
+ } else {
87
+ lines.push(` hydrateRoot(`);
88
+ lines.push(` root,`);
89
+ lines.push(` React.createElement(SynapseProvider, { props, session: props.session as any }, sliceElement)`);
90
+ lines.push(` );`);
91
+ }
92
+
93
+ lines.push(` }`);
94
+ lines.push(`}`);
95
+ lines.push(``);
96
+ lines.push(`hydrate();`);
97
+ lines.push(``);
98
+
99
+ return lines.join('\n');
57
100
  }
58
101
 
59
102
  /**
@@ -356,7 +356,8 @@ export class SynapseServer {
356
356
  for (const cand of layoutCandidates) {
357
357
  if (fs.existsSync(cand)) {
358
358
  try {
359
- const layoutMod = await import(cand);
359
+ const candStat = fs.statSync(cand);
360
+ const layoutMod = await import(`${cand}?t=${candStat.mtimeMs}`);
360
361
  this.layoutComponent = layoutMod.default || layoutMod.Layout || null;
361
362
  break;
362
363
  } catch (e) {
@@ -380,7 +381,8 @@ export class SynapseServer {
380
381
  for (const cand of domainCandidates) {
381
382
  if (fs.existsSync(cand)) {
382
383
  try {
383
- const domainMod = await import(cand);
384
+ const candStat = fs.statSync(cand);
385
+ const domainMod = await import(`${cand}?t=${candStat.mtimeMs}`);
384
386
  const domainComp = domainMod.default || domainMod.Layout || null;
385
387
  if (domainComp) {
386
388
  this.domainLayouts.set(domain, domainComp);
@@ -399,7 +401,8 @@ export class SynapseServer {
399
401
  }
400
402
 
401
403
  try {
402
- const mod = await import(file);
404
+ const fileStat = fs.existsSync(file) ? fs.statSync(file) : null;
405
+ const mod = await import(fileStat ? `${file}?t=${fileStat.mtimeMs}` : file);
403
406
  let actionFn: any = null;
404
407
  let componentFn: any = null;
405
408
  let componentExport: string | undefined;
@@ -624,10 +627,12 @@ export class SynapseServer {
624
627
  return null;
625
628
  }
626
629
 
627
- const signature = String(fs.statSync(slice.filePath).mtimeMs);
630
+ const stat = fs.existsSync(slice.filePath) ? fs.statSync(slice.filePath) : null;
631
+ const signature = stat ? String(stat.mtimeMs) : 'missing';
628
632
  const cached = this.clientBundles.get(slice.key);
629
633
  if (cached?.builtFor === signature) {
630
- return cached.url;
634
+ const version = stat ? `?v=${Math.floor(stat.mtimeMs)}` : '';
635
+ return `${cached.url}${version}`;
631
636
  }
632
637
 
633
638
  // `synapse build` já empacotou esta fatia e o arquivo não mudou desde então:
@@ -639,7 +644,8 @@ export class SynapseServer {
639
644
  const prebuilt = freshBundleFrom(this.clientManifest, slice.key, Number(signature));
640
645
  if (prebuilt) {
641
646
  this.clientBundles.set(slice.key, { url: prebuilt, builtFor: signature });
642
- return prebuilt;
647
+ const version = stat ? `?v=${Math.floor(stat.mtimeMs)}` : '';
648
+ return `${prebuilt}${version}`;
643
649
  }
644
650
 
645
651
  const built = await buildClientBundle(
@@ -660,7 +666,8 @@ export class SynapseServer {
660
666
 
661
667
  this.clientBundles.set(slice.key, { url: built.value.url, builtFor: signature });
662
668
 
663
- return built.value.url;
669
+ const version = stat ? `?v=${Math.floor(stat.mtimeMs)}` : '';
670
+ return `${built.value.url}${version}`;
664
671
  }
665
672
 
666
673
  /**
@@ -696,7 +703,8 @@ export class SynapseServer {
696
703
  propsJson: string,
697
704
  clientUrl: string | null,
698
705
  meta?: SliceMetadata,
699
- locale = 'pt-BR'
706
+ locale = 'pt-BR',
707
+ hasLayout = false
700
708
  ): string {
701
709
  const safeTitle = escapeHtml(title);
702
710
  const safeSliceName = escapeHtml(sliceName);
@@ -729,6 +737,44 @@ export class SynapseServer {
729
737
  }
730
738
  const metaSection = metaTags.length > 0 ? `\n${metaTags.join('\n')}` : '';
731
739
 
740
+ if (hasLayout) {
741
+ return `<!DOCTYPE html>
742
+ <html lang="${escapeHtml(locale)}" class="min-h-full">
743
+ <head>
744
+ <meta charset="UTF-8">
745
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
746
+ <title>${pageTitle}</title>${metaSection}
747
+ ${this.renderStylesheets()}
748
+ <script type="importmap">
749
+ {
750
+ "imports": {
751
+ "react": "/_synapse/client/_vendor.js",
752
+ "react/jsx-runtime": "/_synapse/client/_vendor.js",
753
+ "react/jsx-dev-runtime": "/_synapse/client/_vendor.js",
754
+ "react-dom": "/_synapse/client/_vendor.js",
755
+ "react-dom/client": "/_synapse/client/_vendor.js",
756
+ "synapsejs/client": "/_synapse/client/_vendor.js",
757
+ "@ismaelsoilet/synapsejs/client": "/_synapse/client/_vendor.js"
758
+ }
759
+ }
760
+ </script>
761
+ <script type="module" src="/_synapse/client/_vendor.js"></script>
762
+ <style>
763
+ body { font-family: 'Inter', sans-serif; }
764
+ code, pre { font-family: 'JetBrains Mono', monospace; }
765
+ </style>
766
+ </head>
767
+ <body class="min-h-full flex flex-col">
768
+ <div id="synapse-root" class="min-h-full flex-1 flex flex-col">${contentHtml}</div>
769
+
770
+ <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
771
+ ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
772
+
773
+ <script type="module" src="/_synapse/turbo-router.js"></script>
774
+ </body>
775
+ </html>`;
776
+ }
777
+
732
778
  return `<!DOCTYPE html>
733
779
  <html lang="${escapeHtml(locale)}" class="h-full bg-slate-950 text-slate-100">
734
780
  <head>
@@ -740,9 +786,12 @@ ${this.renderStylesheets()}
740
786
  {
741
787
  "imports": {
742
788
  "react": "/_synapse/client/_vendor.js",
789
+ "react/jsx-runtime": "/_synapse/client/_vendor.js",
790
+ "react/jsx-dev-runtime": "/_synapse/client/_vendor.js",
743
791
  "react-dom": "/_synapse/client/_vendor.js",
744
792
  "react-dom/client": "/_synapse/client/_vendor.js",
745
- "synapsejs/client": "/_synapse/client/_vendor.js"
793
+ "synapsejs/client": "/_synapse/client/_vendor.js",
794
+ "@ismaelsoilet/synapsejs/client": "/_synapse/client/_vendor.js"
746
795
  }
747
796
  }
748
797
  </script>
@@ -1501,6 +1550,7 @@ ${this.renderStylesheets()}
1501
1550
 
1502
1551
  this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
1503
1552
 
1553
+ const hasCustomLayout = Boolean(this.layoutComponent || this.domainLayouts.get(slice.domain));
1504
1554
  const html = this.renderHtmlShell(
1505
1555
  meta?.title || slice.name,
1506
1556
  contentHtml,
@@ -1508,7 +1558,8 @@ ${this.renderStylesheets()}
1508
1558
  serializeClientProps(dataProps),
1509
1559
  clientUrl,
1510
1560
  meta,
1511
- matchedLocale || 'pt-BR'
1561
+ matchedLocale || 'pt-BR',
1562
+ hasCustomLayout
1512
1563
  );
1513
1564
  return new Response(html, {
1514
1565
  headers: { 'Content-Type': 'text/html; charset=utf-8' }