@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 +1 -1
- package/src/client/hooks.ts +25 -4
- package/src/client.ts +1 -0
- package/src/compiler/client-bundler.ts +114 -3
- package/src/compiler/scaffolder.ts +2 -2
- package/src/compiler/schema-dag.ts +12 -8
- package/src/compiler/slice-splitter.ts +10 -2
- package/src/compiler/slice-templates.ts +7 -7
- package/src/core/action-definition.ts +89 -0
- package/src/core/index.ts +2 -0
- package/src/core/validation.ts +57 -0
- package/src/runtime/client-entry.ts +58 -15
- package/src/runtime/server.ts +61 -10
package/package.json
CHANGED
package/src/client/hooks.ts
CHANGED
|
@@ -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 (!
|
|
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
|
|
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
|
-
[
|
|
160
|
+
[resolvedAction]
|
|
140
161
|
);
|
|
141
162
|
|
|
142
163
|
return {
|
package/src/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
? [
|
|
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
|
-
//
|
|
29
|
-
const
|
|
30
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
82
|
-
{ label: '
|
|
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) => `
|
|
31
|
+
.map((name) => ` ${name}: (payload: unknown) => rpcCall(${JSON.stringify(options.rpcPath)}, payload)`)
|
|
30
32
|
.join(',\n');
|
|
31
33
|
|
|
32
|
-
|
|
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
|
-
`
|
|
45
|
-
`
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
/**
|
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' }
|