@ismaelsoilet/synapsejs 1.4.0 → 1.9.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 +108 -10
- package/bin/synapse.ts +263 -218
- 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/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +144 -11
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +174 -3
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +67 -6
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +101 -8
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +124 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +5 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +45 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +115 -8
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +61 -24
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +10 -2
- package/src/mcp/server.ts +78 -173
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +182 -0
- package/src/runtime/queue-engine.ts +102 -21
- package/src/runtime/rate-limiter.ts +24 -0
- package/src/runtime/server.ts +1380 -251
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +11 -0
- package/templates/starter/.jev.json +1 -1
- package/templates/starter/tsconfig.json +1 -0
|
@@ -42,7 +42,12 @@ export interface SplitResult {
|
|
|
42
42
|
artifacts: SplitArtifact[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export type SplitErrorCode =
|
|
45
|
+
export type SplitErrorCode =
|
|
46
|
+
| 'SLICE_NOT_FOUND'
|
|
47
|
+
| 'SLICE_IMPORTS_SLICE'
|
|
48
|
+
| 'NO_ROOTS'
|
|
49
|
+
| 'UNPARSED'
|
|
50
|
+
| 'SERVER_VALUE_IN_CLIENT';
|
|
46
51
|
|
|
47
52
|
export interface SplitError {
|
|
48
53
|
code: SplitErrorCode;
|
|
@@ -67,10 +72,14 @@ export interface SplitVerification {
|
|
|
67
72
|
const CLIENT_ROOTS = [...COMPONENT_SUFFIXES];
|
|
68
73
|
const TEST_ONLY_NAMES: readonly string[] = [...TEST_ONLY_EXPORTS];
|
|
69
74
|
|
|
70
|
-
|
|
75
|
+
/** The expressions that must never survive into a client bundle. Exported so the
|
|
76
|
+
* linear-time guarantee can be asserted directly against each of them. */
|
|
77
|
+
export const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
|
|
71
78
|
// `SELECT ... FROM` e não `SELECT` seguido de qualquer palavra: sem o FROM, a
|
|
72
79
|
// tag HTML `<select id="x">` de um formulário casava como vazamento de SQL.
|
|
73
|
-
|
|
80
|
+
// O quantificador é limitado (uma janela fixa, não `*`): um bundle grande com
|
|
81
|
+
// muitos `SELECT` sem `FROM` fazia o motor retroceder sobre o texto inteiro.
|
|
82
|
+
{ label: 'SELECT statement', pattern: /\bSELECT\s[\w*"`.,\s]{0,4096}?\bFROM\b/i },
|
|
74
83
|
{ label: 'INSERT statement', pattern: /\bINSERT\s+INTO\b/i },
|
|
75
84
|
{ label: 'UPDATE statement', pattern: /\bUPDATE\s+\w+\s+SET\b/i },
|
|
76
85
|
{ label: 'DELETE statement', pattern: /\bDELETE\s+FROM\b/i },
|
|
@@ -82,11 +91,23 @@ const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
|
|
|
82
91
|
{ label: 'test runner import', pattern: /from\s+['"]bun:test['"]/ },
|
|
83
92
|
{ label: 'process.env access', pattern: /\bprocess\s*(?:\.\s*env|\[\s*['"]env['"]\s*\])/ },
|
|
84
93
|
{ label: 'process destructuring', pattern: /\b(?:const|let|var)\s*\{[^}]*\benv\b[^}]*\}\s*=\s*process\b/ },
|
|
85
|
-
{ label: 'Bun global', pattern: /\bBun
|
|
94
|
+
{ label: 'Bun global', pattern: /\bBun(?:\.|\s*\[)/ },
|
|
95
|
+
{
|
|
96
|
+
label: 'globalThis server access',
|
|
97
|
+
pattern: /\bglobalThis\s*(?:\.\s*(?:process|Bun)|\[\s*['"](?:process|Bun)['"]\s*\])/
|
|
98
|
+
},
|
|
86
99
|
{
|
|
87
100
|
label: 'node server module import',
|
|
88
101
|
pattern: /from\s+['"](?:node:)?(?:fs|child_process|cluster|dgram|dns|net|tls|v8|vm)['"]/
|
|
89
102
|
},
|
|
103
|
+
{
|
|
104
|
+
label: 'server dynamic import',
|
|
105
|
+
pattern: /\bimport\s*\(\s*['"](?:node:)?(?:fs|child_process|sqlite|postgres|bun|cluster|net|tls)['"]/
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
label: 'server require leak',
|
|
109
|
+
pattern: /\brequire\s*\(\s*['"](?:node:)?(?:fs|child_process|sqlite|postgres|bun|cluster|net|tls)['"]/
|
|
110
|
+
},
|
|
90
111
|
{ label: 'dynamic code evaluation', pattern: /\b(?:eval|new\s+Function)\s*\(/ }
|
|
91
112
|
];
|
|
92
113
|
|
|
@@ -495,6 +516,20 @@ function unwrapPromiseType(typeText: string): string {
|
|
|
495
516
|
return match ? match[1].trim() : `Awaited<${typeText}>`;
|
|
496
517
|
}
|
|
497
518
|
|
|
519
|
+
/**
|
|
520
|
+
* A wire stub describes a server *function*. A server-owned value that a component
|
|
521
|
+
* references has no wire form, so the caller must fail with a diagnostic instead of
|
|
522
|
+
* emitting a stub that would call the endpoint as if it were an action.
|
|
523
|
+
*/
|
|
524
|
+
function isStubCandidate(entry: DeclEntry): boolean {
|
|
525
|
+
return (
|
|
526
|
+
ts.isFunctionDeclaration(entry.node) ||
|
|
527
|
+
ts.isFunctionExpression(entry.node) ||
|
|
528
|
+
ts.isArrowFunction(entry.node) ||
|
|
529
|
+
ts.isMethodDeclaration(entry.node)
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
498
533
|
function renderRpcStub(entry: DeclEntry, endpoint: string, sourceFile: ts.SourceFile): string {
|
|
499
534
|
const fn = entry.node as ts.FunctionDeclaration;
|
|
500
535
|
const payload = fn.parameters[0];
|
|
@@ -603,7 +638,8 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
|
|
|
603
638
|
message:
|
|
604
639
|
`Uma fatia não importa outra: ${path.basename(sliceFilePath)} alcança ` +
|
|
605
640
|
`${path.relative(process.cwd(), illegalImport)}. Mova o código comum para ` +
|
|
606
|
-
'src/shared/<nome>.ts, receba o db por parâmetro e chame de dentro das duas fatias.'
|
|
641
|
+
'src/shared/<nome>.ts, receba o db por parâmetro e chame de dentro das duas fatias. ' +
|
|
642
|
+
'Dica: use `synapse new-shared <nome>` para criar um módulo compartilhado seguro em src/shared/.',
|
|
607
643
|
candidates: [illegalImport]
|
|
608
644
|
});
|
|
609
645
|
}
|
|
@@ -699,6 +735,27 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
|
|
|
699
735
|
const sliceName = path.basename(sliceFilePath, '.slice.tsx');
|
|
700
736
|
const relativePath = path.relative(baseDir, sliceFilePath);
|
|
701
737
|
const endpoint = `/_synapse/rpc/${sliceName}`;
|
|
738
|
+
|
|
739
|
+
const serverValueInClient = stubbedKeys.find((key) => {
|
|
740
|
+
const entry = entries.get(key);
|
|
741
|
+
|
|
742
|
+
return entry ? !isStubCandidate(entry) : false;
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
if (serverValueInClient) {
|
|
746
|
+
const entry = entries.get(serverValueInClient) as DeclEntry;
|
|
747
|
+
|
|
748
|
+
return Err({
|
|
749
|
+
code: 'SERVER_VALUE_IN_CLIENT',
|
|
750
|
+
message:
|
|
751
|
+
`O componente de ${path.basename(sliceFilePath)} referencia '${serverValueInClient}', ` +
|
|
752
|
+
`que é um valor do servidor (${ts.SyntaxKind[entry.node.kind]}) e não uma action: não existe ` +
|
|
753
|
+
`forma de transportá-lo pelo endpoint RPC. Mova o valor para um módulo em src/shared/, ` +
|
|
754
|
+
`importe-o de 'synapsejs/client', ou limite-se a declará-lo no servidor.`,
|
|
755
|
+
candidates: [serverValueInClient]
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
702
759
|
const artifacts: SplitArtifact[] = [];
|
|
703
760
|
const outDir = artifactDirectory(baseDir, sliceName);
|
|
704
761
|
const sourceDir = path.dirname(sliceFilePath);
|
|
@@ -831,8 +888,12 @@ export function verifySplit(result: SplitResult, outDir: string): SplitVerificat
|
|
|
831
888
|
leaks.push('pacote-raiz-no-cliente');
|
|
832
889
|
}
|
|
833
890
|
|
|
891
|
+
const normalizedCode = clientArtifact.code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
|
|
892
|
+
String.fromCharCode(parseInt(hex, 16))
|
|
893
|
+
);
|
|
894
|
+
|
|
834
895
|
for (const { label, pattern } of SERVER_ONLY_PATTERNS) {
|
|
835
|
-
if (pattern.test(clientArtifact.code)) {
|
|
896
|
+
if (pattern.test(clientArtifact.code) || pattern.test(normalizedCode)) {
|
|
836
897
|
leaks.push(label);
|
|
837
898
|
}
|
|
838
899
|
}
|
|
@@ -820,6 +820,9 @@ export const sliceSchema = \`
|
|
|
820
820
|
roles TEXT NOT NULL DEFAULT 'user',
|
|
821
821
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
822
822
|
);
|
|
823
|
+
|
|
824
|
+
-- down:
|
|
825
|
+
DROP TABLE IF EXISTS users;
|
|
823
826
|
\`;
|
|
824
827
|
|
|
825
828
|
// Para criar o primeiro usuário, num seed fora da fatia:
|
|
@@ -1099,6 +1102,10 @@ export const sliceSchema = \`
|
|
|
1099
1102
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
1100
1103
|
UNIQUE(provider, provider_user_id)
|
|
1101
1104
|
);
|
|
1105
|
+
|
|
1106
|
+
-- down:
|
|
1107
|
+
DROP TABLE IF EXISTS oauth_accounts;
|
|
1108
|
+
DROP TABLE IF EXISTS users;
|
|
1102
1109
|
\`;
|
|
1103
1110
|
|
|
1104
1111
|
// ============================================================================
|
|
@@ -1366,6 +1373,9 @@ export const sliceSchema = \`
|
|
|
1366
1373
|
enabled INTEGER DEFAULT 1,
|
|
1367
1374
|
verified_at TIMESTAMP
|
|
1368
1375
|
);
|
|
1376
|
+
|
|
1377
|
+
-- down:
|
|
1378
|
+
DROP TABLE IF EXISTS ${names.table};
|
|
1369
1379
|
\`;
|
|
1370
1380
|
|
|
1371
1381
|
// ============================================================================
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import { Err, Ok, type Result } from '../core/machine-types';
|
|
12
|
+
import { SYNAPSE_VERSION } from '../version';
|
|
12
13
|
import { type BuildReportEntry, buildAllClientBundles } from './client-bundler';
|
|
13
14
|
import { resolveSlicesDir, type SlicesDirError } from './slice-discovery';
|
|
14
15
|
|
|
@@ -60,10 +61,23 @@ export async function buildStandalone(
|
|
|
60
61
|
fs.mkdirSync(path.dirname(slicesDest), { recursive: true });
|
|
61
62
|
fs.cpSync(slicesSrc, slicesDest, { recursive: true });
|
|
62
63
|
|
|
63
|
-
// 5. Copy package.json & tsconfig.json if present
|
|
64
|
+
// 5. Copy package.json & tsconfig.json if present (normalizing workspace:* dependencies)
|
|
64
65
|
const pkgSrc = path.join(appRoot, 'package.json');
|
|
65
66
|
if (fs.existsSync(pkgSrc)) {
|
|
66
|
-
|
|
67
|
+
try {
|
|
68
|
+
const rawPkg = fs.readFileSync(pkgSrc, 'utf-8');
|
|
69
|
+
const pkg = JSON.parse(rawPkg);
|
|
70
|
+
if (pkg.dependencies) {
|
|
71
|
+
for (const [dep, ver] of Object.entries(pkg.dependencies)) {
|
|
72
|
+
if (typeof ver === 'string' && ver.startsWith('workspace:')) {
|
|
73
|
+
pkg.dependencies[dep] = `^${SYNAPSE_VERSION}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
fs.writeFileSync(path.join(standaloneDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`, 'utf-8');
|
|
78
|
+
} catch {
|
|
79
|
+
fs.copyFileSync(pkgSrc, path.join(standaloneDir, 'package.json'));
|
|
80
|
+
}
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
const tsconfigSrc = path.join(appRoot, 'tsconfig.json');
|
|
@@ -10,6 +10,7 @@ import { getEventHub } from '../runtime/event-hub';
|
|
|
10
10
|
import type { DatabaseClient, QueryOptions, WhereCondition } from './database-client';
|
|
11
11
|
import type { SessionContext } from './session-context';
|
|
12
12
|
import { getStorage, type StorageClient } from './storage';
|
|
13
|
+
import { declaredTopic, scopedTopic } from './topics';
|
|
13
14
|
|
|
14
15
|
export interface StructuredLogger {
|
|
15
16
|
info(message: string, context?: Record<string, unknown>): void;
|
|
@@ -31,6 +32,60 @@ export type EnqueueFn = <TPayload>(
|
|
|
31
32
|
|
|
32
33
|
export type BroadcastFn = (topic: string, data: unknown) => number;
|
|
33
34
|
|
|
35
|
+
export interface CookieOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Defaults to true. A cookie written without stating otherwise is not readable by
|
|
38
|
+
* script; pass `false` explicitly to opt out.
|
|
39
|
+
*/
|
|
40
|
+
httpOnly?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Defaults to true. A cookie written without stating otherwise is not sent over
|
|
43
|
+
* plaintext; pass `false` explicitly to opt out.
|
|
44
|
+
*/
|
|
45
|
+
secure?: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Defaults to `lax`. An explicit `strict` or `none` applies instead — the framework
|
|
48
|
+
* never infers a weaker policy from anything but a deliberate statement.
|
|
49
|
+
*/
|
|
50
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
51
|
+
maxAge?: number;
|
|
52
|
+
path?: string;
|
|
53
|
+
domain?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PendingCookie {
|
|
57
|
+
name: string;
|
|
58
|
+
value: string;
|
|
59
|
+
options?: CookieOptions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Serializes one response cookie. The safe attributes are the **defaults**: a cookie
|
|
64
|
+
* written with no options at all is HttpOnly, Secure and SameSite=Lax, and only an
|
|
65
|
+
* explicit `false` (or a different `sameSite`) changes that. This is a breaking
|
|
66
|
+
* change from the previous opt-in behaviour.
|
|
67
|
+
*/
|
|
68
|
+
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
69
|
+
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
70
|
+
if (options.maxAge !== undefined) {
|
|
71
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
72
|
+
}
|
|
73
|
+
if (options.domain && !/[\r\n;\s]/.test(options.domain)) {
|
|
74
|
+
parts.push(`Domain=${options.domain.trim()}`);
|
|
75
|
+
}
|
|
76
|
+
const cleanPath = options.path && !/[\r\n;\s]/.test(options.path) ? options.path.trim() : '/';
|
|
77
|
+
parts.push(`Path=${cleanPath}`);
|
|
78
|
+
if (options.secure ?? true) {
|
|
79
|
+
parts.push('Secure');
|
|
80
|
+
}
|
|
81
|
+
if (options.httpOnly ?? true) {
|
|
82
|
+
parts.push('HttpOnly');
|
|
83
|
+
}
|
|
84
|
+
const sameSite = options.sameSite ?? 'lax';
|
|
85
|
+
parts.push(`SameSite=${sameSite.charAt(0).toUpperCase() + sameSite.slice(1).toLowerCase()}`);
|
|
86
|
+
return parts.join('; ');
|
|
87
|
+
}
|
|
88
|
+
|
|
34
89
|
export interface ActionContext<TServices = Record<string, unknown>> extends DatabaseClient {
|
|
35
90
|
/** Underlying Database Client */
|
|
36
91
|
readonly db: DatabaseClient;
|
|
@@ -56,8 +111,14 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
|
|
|
56
111
|
/** Broadcasts a real-time event to SSE subscribers */
|
|
57
112
|
readonly broadcast: BroadcastFn;
|
|
58
113
|
|
|
59
|
-
/**
|
|
60
|
-
readonly
|
|
114
|
+
/** Invalidates SSR cache entries by tag or completely */
|
|
115
|
+
readonly invalidateCache: (tags?: string[]) => void;
|
|
116
|
+
|
|
117
|
+
/** Sets an HTTP response cookie (e.g. HttpOnly session token) */
|
|
118
|
+
readonly setCookie: (name: string, value: string, options?: CookieOptions) => void;
|
|
119
|
+
|
|
120
|
+
/** Internal list of pending cookies to be set on the response */
|
|
121
|
+
readonly _pendingCookies?: PendingCookie[];
|
|
61
122
|
}
|
|
62
123
|
|
|
63
124
|
export function createDefaultLogger(sliceName?: string): StructuredLogger {
|
|
@@ -83,7 +144,12 @@ export interface ActionContextOptions<TServices = Record<string, unknown>> {
|
|
|
83
144
|
enqueue?: EnqueueFn;
|
|
84
145
|
storage?: StorageClient;
|
|
85
146
|
broadcast?: BroadcastFn;
|
|
86
|
-
|
|
147
|
+
invalidateCache?: (tags?: string[]) => void;
|
|
148
|
+
/**
|
|
149
|
+
* The slice key (`<domain>/<name>`) this context belongs to. Publishing into a
|
|
150
|
+
* declared topic is only allowed for the declaration's owner.
|
|
151
|
+
*/
|
|
152
|
+
sliceOwner?: string;
|
|
87
153
|
}
|
|
88
154
|
|
|
89
155
|
/**
|
|
@@ -101,10 +167,35 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
101
167
|
logger = createDefaultLogger(),
|
|
102
168
|
enqueue = async () => 'job_noop',
|
|
103
169
|
storage = getStorage(),
|
|
104
|
-
|
|
105
|
-
|
|
170
|
+
invalidateCache = () => {},
|
|
171
|
+
sliceOwner
|
|
106
172
|
} = options;
|
|
107
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Publishing is declared, not inferred: the topic must exist in the session's
|
|
176
|
+
* tenant scope and this feature must be its declared owner. Anything else is
|
|
177
|
+
* rejected and notifies nobody.
|
|
178
|
+
*/
|
|
179
|
+
const broadcast: BroadcastFn =
|
|
180
|
+
options.broadcast ??
|
|
181
|
+
((topic: string, data: unknown) => {
|
|
182
|
+
const declaration = declaredTopic(topic, tenantId);
|
|
183
|
+
|
|
184
|
+
if (!declaration || (sliceOwner && declaration.owner !== sliceOwner)) {
|
|
185
|
+
logger.warn(
|
|
186
|
+
`Broadcast recusado no tópico "${topic}": não declarado${sliceOwner ? ` por ${sliceOwner}` : ''} (defineTopic).`
|
|
187
|
+
);
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return getEventHub().publish(scopedTopic(topic, tenantId), data);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const pendingCookies: PendingCookie[] = [];
|
|
195
|
+
const setCookie = (name: string, value: string, options?: CookieOptions) => {
|
|
196
|
+
pendingCookies.push({ name, value, options });
|
|
197
|
+
};
|
|
198
|
+
|
|
108
199
|
const context: ActionContext<TServices> = {
|
|
109
200
|
// ActionContext properties
|
|
110
201
|
db,
|
|
@@ -115,7 +206,9 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
115
206
|
enqueue,
|
|
116
207
|
storage,
|
|
117
208
|
broadcast,
|
|
118
|
-
|
|
209
|
+
invalidateCache,
|
|
210
|
+
setCookie,
|
|
211
|
+
_pendingCookies: pendingCookies,
|
|
119
212
|
|
|
120
213
|
// DatabaseClient proxy methods
|
|
121
214
|
query: <T = unknown>(sql: string, params?: unknown[]) => db.query<T>(sql, params),
|
|
@@ -139,8 +232,8 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
139
232
|
logger,
|
|
140
233
|
enqueue,
|
|
141
234
|
storage,
|
|
142
|
-
broadcast,
|
|
143
|
-
|
|
235
|
+
broadcast: options.broadcast,
|
|
236
|
+
sliceOwner
|
|
144
237
|
});
|
|
145
238
|
return operation(txContext);
|
|
146
239
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SynapseJS - Incremental Static Regeneration (ISR) & SSR Micro-Cache Contract
|
|
3
|
+
*
|
|
4
|
+
* Slices export `sliceCache` or `cacheConfig` to declare caching boundaries,
|
|
5
|
+
* stale-while-revalidate windows, and tag-based invalidation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SliceCacheConfig {
|
|
9
|
+
/** Time to live in seconds before the cached SSR HTML is considered stale */
|
|
10
|
+
ttlSeconds: number;
|
|
11
|
+
/** Stale-while-revalidate grace period in seconds */
|
|
12
|
+
staleWhileRevalidateSeconds?: number;
|
|
13
|
+
/** Invalidation tags (e.g. ['products', 'catalog']) */
|
|
14
|
+
tags?: string[];
|
|
15
|
+
/** Allowed query parameters to differentiate cache entries (default: all non-tracking parameters) */
|
|
16
|
+
allowedParams?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Declares SSR caching and ISR revalidation policy for a slice.
|
|
21
|
+
*/
|
|
22
|
+
export function defineCache(config: SliceCacheConfig): SliceCacheConfig {
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TRACKING_PARAMS = new Set([
|
|
27
|
+
'utm_source',
|
|
28
|
+
'utm_medium',
|
|
29
|
+
'utm_campaign',
|
|
30
|
+
'utm_term',
|
|
31
|
+
'utm_content',
|
|
32
|
+
'fbclid',
|
|
33
|
+
'gclid',
|
|
34
|
+
'msclkid',
|
|
35
|
+
'mc_eid',
|
|
36
|
+
'_ga',
|
|
37
|
+
'_gl',
|
|
38
|
+
'ref',
|
|
39
|
+
'v'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalizes a URL into a deterministic SSR cache key:
|
|
44
|
+
* - Strips common ad/tracking query parameters (utm_*, fbclid, etc.)
|
|
45
|
+
* - If allowedParams is specified, filters query parameters strictly to that allowlist
|
|
46
|
+
* - Sorts query parameters deterministically to avoid duplicate cache entries for different key orders
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeCacheKey(url: URL, allowedParams?: string[]): string {
|
|
49
|
+
const pathname = url.pathname;
|
|
50
|
+
const searchParams = new URLSearchParams(url.search);
|
|
51
|
+
|
|
52
|
+
const cleanParams = new URLSearchParams();
|
|
53
|
+
const allowedSet = allowedParams ? new Set(allowedParams) : null;
|
|
54
|
+
|
|
55
|
+
const sortedKeys = Array.from(searchParams.keys()).sort();
|
|
56
|
+
for (const key of sortedKeys) {
|
|
57
|
+
if (allowedSet) {
|
|
58
|
+
if (allowedSet.has(key)) {
|
|
59
|
+
for (const val of searchParams.getAll(key)) {
|
|
60
|
+
cleanParams.append(key, val);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else if (!DEFAULT_TRACKING_PARAMS.has(key.toLowerCase()) && !key.toLowerCase().startsWith('utm_')) {
|
|
64
|
+
for (const val of searchParams.getAll(key)) {
|
|
65
|
+
cleanParams.append(key, val);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const queryStr = cleanParams.toString();
|
|
71
|
+
return queryStr ? `${pathname}?${queryStr}` : pathname;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* In-memory Bounded LRU Cache with O(1) get/set and automatic eviction of least recently used entries.
|
|
76
|
+
*/
|
|
77
|
+
export class BoundedLruCache<V> {
|
|
78
|
+
private map = new Map<string, V>();
|
|
79
|
+
readonly maxEntries: number;
|
|
80
|
+
|
|
81
|
+
constructor(maxEntries = 1000) {
|
|
82
|
+
this.maxEntries = Math.max(1, maxEntries);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get(key: string): V | undefined {
|
|
86
|
+
const val = this.map.get(key);
|
|
87
|
+
if (val !== undefined) {
|
|
88
|
+
// Re-insert to mark as most recently used (MRU)
|
|
89
|
+
this.map.delete(key);
|
|
90
|
+
this.map.set(key, val);
|
|
91
|
+
}
|
|
92
|
+
return val;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
set(key: string, value: V): void {
|
|
96
|
+
if (this.map.has(key)) {
|
|
97
|
+
this.map.delete(key);
|
|
98
|
+
} else if (this.map.size >= this.maxEntries) {
|
|
99
|
+
// Evict least recently used (first key in map iteration order)
|
|
100
|
+
const oldestKey = this.map.keys().next().value;
|
|
101
|
+
if (oldestKey !== undefined) {
|
|
102
|
+
this.map.delete(oldestKey);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.map.set(key, value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
has(key: string): boolean {
|
|
109
|
+
return this.map.has(key);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
delete(key: string): boolean {
|
|
113
|
+
return this.map.delete(key);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clear(): void {
|
|
117
|
+
this.map.clear();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get size(): number {
|
|
121
|
+
return this.map.size;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
entries(): IterableIterator<[string, V]> {
|
|
125
|
+
return this.map.entries();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
values(): IterableIterator<V> {
|
|
129
|
+
return this.map.values();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
keys(): IterableIterator<string> {
|
|
133
|
+
return this.map.keys();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
[Symbol.iterator](): IterableIterator<[string, V]> {
|
|
137
|
+
return this.map[Symbol.iterator]();
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/core/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface SynapsePlugin {
|
|
|
11
11
|
name: string;
|
|
12
12
|
// biome-ignore lint/suspicious/noExplicitAny: generic boundary for framework server
|
|
13
13
|
onBootstrap?: (server: any) => Promise<void> | void;
|
|
14
|
-
onRequest?: (req: Request) => Promise<Response | null |
|
|
14
|
+
onRequest?: (req: Request) => Promise<Response | null | undefined> | Response | null | undefined;
|
|
15
15
|
onResponse?: (res: Response, req: Request) => Promise<Response> | Response;
|
|
16
16
|
// biome-ignore lint/suspicious/noExplicitAny: generic boundary for database client
|
|
17
17
|
onMigrate?: (db: any) => Promise<void> | void;
|
|
@@ -45,6 +45,107 @@ export interface SynapseConfig<TServices = Record<string, unknown>> {
|
|
|
45
45
|
* Infrastructure lifecycle plugins (onBootstrap, onRequest, onResponse, onMigrate).
|
|
46
46
|
*/
|
|
47
47
|
plugins?: SynapsePlugin[];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compiler and client bundler configuration.
|
|
51
|
+
*/
|
|
52
|
+
compiler?: {
|
|
53
|
+
vendorPackages?: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Trust proxy headers (cf-connecting-ip, x-forwarded-for) for rate limiting and IP resolution.
|
|
58
|
+
* Opt-in: when this is not explicitly `true` (or SYNAPSE_TRUST_PROXY is not `true`), the
|
|
59
|
+
* immediate peer address is used and a rotating forwarding header cannot mint a new allowance.
|
|
60
|
+
*/
|
|
61
|
+
trustProxy?: boolean;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Process-level maximum request body size, enforced by the HTTP server itself before any
|
|
65
|
+
* body reader runs (default: 12 MiB, above every per-route ceiling). It is the backstop
|
|
66
|
+
* behind the RPC (5 MiB), webhook (10 MiB) and upload (5 MiB) limits.
|
|
67
|
+
*/
|
|
68
|
+
maxRequestBodyBytes?: number;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rate limiting. Scope is the serving process only: each instance keeps its own counters,
|
|
72
|
+
* so N instances behind a load balancer permit N times the per-instance allowance.
|
|
73
|
+
*/
|
|
74
|
+
rateLimit?: {
|
|
75
|
+
/** Maximum burst capacity (default: 150). */
|
|
76
|
+
capacity?: number;
|
|
77
|
+
/** Refill rate in tokens per second (default: 100). */
|
|
78
|
+
refillRate?: number;
|
|
79
|
+
/** Maximum tracked client identities before the registry is saturated (default: 10,000). */
|
|
80
|
+
maxBuckets?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Max allowed payload bytes for RPC POST requests (default: 5MB).
|
|
85
|
+
*/
|
|
86
|
+
maxRpcPayloadBytes?: number;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Max allowed payload bytes for Webhook POST requests (default: 10MB).
|
|
90
|
+
*/
|
|
91
|
+
maxWebhookPayloadBytes?: number;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* SSR Cache settings.
|
|
95
|
+
*/
|
|
96
|
+
cache?: {
|
|
97
|
+
maxEntries?: number;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Third-party CDN and web-font origins (Tailwind's runtime compiler, Google Fonts).
|
|
102
|
+
* **Opt-in**: nothing third-party is referenced by default. When enabled, the
|
|
103
|
+
* content-security policy is rebuilt to permit exactly the configured origins, and
|
|
104
|
+
* the emitted tags carry a subresource-integrity hash — without one, the tag is not
|
|
105
|
+
* emitted at all, because an unpinned third-party script in an origin that holds a
|
|
106
|
+
* script-readable session cookie is the whole risk.
|
|
107
|
+
*/
|
|
108
|
+
cdn?: {
|
|
109
|
+
enabled?: boolean;
|
|
110
|
+
/** SRI hash for the Tailwind runtime script (`sha384-...`). */
|
|
111
|
+
scriptIntegrity?: string;
|
|
112
|
+
/** SRI hash for the Google Fonts stylesheet (`sha384-...`). */
|
|
113
|
+
fontIntegrity?: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Realtime (event stream and WebSocket) limits. Long-lived connections consume a
|
|
118
|
+
* token from the rate limiter at most once, so these bounds — not the token bucket —
|
|
119
|
+
* are what caps concurrent streams.
|
|
120
|
+
*/
|
|
121
|
+
realtime?: {
|
|
122
|
+
/** Maximum distinct topics with an open subscription (default: 1000). */
|
|
123
|
+
maxTopics?: number;
|
|
124
|
+
/** Maximum concurrently open realtime connections (default: 1000). */
|
|
125
|
+
maxSubscriptions?: number;
|
|
126
|
+
/** Maximum concurrently open realtime connections from one client (default: 8). */
|
|
127
|
+
maxConnectionsPerClient?: number;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Operator-supplied Content-Security-Policy. When present it is emitted as given —
|
|
132
|
+
* the framework never weakens it — and replaces the built-in policy.
|
|
133
|
+
*/
|
|
134
|
+
contentSecurityPolicy?: string;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Image optimizer security settings.
|
|
138
|
+
* When `allowedDomains` is absent, any publicly routable host is permitted (loopback,
|
|
139
|
+
* link-local, private and metadata addresses are always refused) and the documentation
|
|
140
|
+
* says so rather than implying a restriction that is not enforced.
|
|
141
|
+
*/
|
|
142
|
+
imageOptimizer?: {
|
|
143
|
+
allowedDomains?: string[];
|
|
144
|
+
/** Maximum bytes accepted from a remote image host (default: 10 MiB). */
|
|
145
|
+
maxResponseBytes?: number;
|
|
146
|
+
/** Maximum time a remote image fetch may take, in ms (default: 10,000). */
|
|
147
|
+
timeoutMs?: number;
|
|
148
|
+
};
|
|
48
149
|
}
|
|
49
150
|
|
|
50
151
|
export function defineConfig<TServices = Record<string, unknown>>(
|
|
@@ -57,20 +158,33 @@ const CONFIG_FILENAMES = ['synapse.config.ts', 'synapse.config.js', 'synapse.con
|
|
|
57
158
|
|
|
58
159
|
/**
|
|
59
160
|
* Discovers and loads `synapse.config.(ts|js|mjs)` from the application root.
|
|
161
|
+
*
|
|
162
|
+
* A configuration file that exists but cannot be loaded is **fatal**: the config
|
|
163
|
+
* carries security-relevant defaults (trusted proxy, body ceilings, realtime bounds),
|
|
164
|
+
* so degrading to an empty configuration silently would start the server with
|
|
165
|
+
* settings nobody chose. Absence of the file is not an error — it means "defaults".
|
|
60
166
|
*/
|
|
61
167
|
export async function loadSynapseConfig(appDir: string): Promise<SynapseConfig> {
|
|
62
168
|
for (const filename of CONFIG_FILENAMES) {
|
|
63
169
|
const candidate = path.join(appDir, filename);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const config = mod.default || mod;
|
|
68
|
-
return typeof config === 'function' ? await config() : config;
|
|
69
|
-
} catch (err: unknown) {
|
|
70
|
-
const errMessage = err instanceof Error ? err.message : String(err);
|
|
71
|
-
console.warn(`[SynapseConfig] Aviso ao carregar ${filename}: ${errMessage}`);
|
|
72
|
-
}
|
|
170
|
+
|
|
171
|
+
if (!fs.existsSync(candidate)) {
|
|
172
|
+
continue;
|
|
73
173
|
}
|
|
174
|
+
|
|
175
|
+
let mod: { default?: unknown } & Record<string, unknown>;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
mod = (await import(candidate)) as { default?: unknown } & Record<string, unknown>;
|
|
179
|
+
} catch (err: unknown) {
|
|
180
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
181
|
+
|
|
182
|
+
throw new Error(`[SynapseConfig] falha ao carregar ${filename}: ${errMessage}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const config = (mod.default || mod) as SynapseConfig | (() => SynapseConfig | Promise<SynapseConfig>);
|
|
186
|
+
|
|
187
|
+
return typeof config === 'function' ? await config() : config;
|
|
74
188
|
}
|
|
75
189
|
|
|
76
190
|
return {};
|