@ismaelsoilet/synapsejs 1.4.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -8
- package/bin/synapse.ts +70 -3
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/migration-runner.ts +134 -9
- package/src/compiler/scaffolder.ts +62 -0
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-splitter.ts +20 -3
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +56 -0
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +37 -1
- package/src/core/index.ts +2 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +27 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +7 -1
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +1 -5
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +8 -1
- package/src/mcp/server.ts +63 -1
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +180 -0
- package/src/runtime/queue-engine.ts +55 -21
- package/src/runtime/rate-limiter.ts +14 -0
- package/src/runtime/server.ts +572 -139
- package/src/version.ts +4 -0
- package/templates/starter/.jev.json +1 -1
|
@@ -63,14 +63,114 @@ export interface RollbackOptions {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function parseBidirectionalDdl(rawDdl: string): BidirectionalDdl {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
let inSingleQuote = false;
|
|
67
|
+
let inDoubleQuote = false;
|
|
68
|
+
let inBlockComment = false;
|
|
69
|
+
let dollarTag: string | null = null;
|
|
70
|
+
let splitIndex = -1;
|
|
71
|
+
let matchLength = 0;
|
|
72
|
+
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < rawDdl.length) {
|
|
75
|
+
const char = rawDdl[i];
|
|
76
|
+
const next = rawDdl[i + 1] ?? '';
|
|
77
|
+
|
|
78
|
+
if (inBlockComment) {
|
|
79
|
+
if (char === '*' && next === '/') {
|
|
80
|
+
inBlockComment = false;
|
|
81
|
+
i += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (inSingleQuote) {
|
|
89
|
+
if (char === '\\') {
|
|
90
|
+
i += 2;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (char === "'") {
|
|
94
|
+
if (next === "'") {
|
|
95
|
+
i += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
inSingleQuote = false;
|
|
99
|
+
}
|
|
100
|
+
i++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (inDoubleQuote) {
|
|
105
|
+
if (char === '"') {
|
|
106
|
+
if (next === '"') {
|
|
107
|
+
i += 2;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
inDoubleQuote = false;
|
|
111
|
+
}
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (dollarTag !== null) {
|
|
117
|
+
if (char === '$' && rawDdl.startsWith(dollarTag, i)) {
|
|
118
|
+
i += dollarTag.length;
|
|
119
|
+
dollarTag = null;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
i++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (char === '/' && next === '*') {
|
|
127
|
+
inBlockComment = true;
|
|
128
|
+
i += 2;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (char === "'") {
|
|
133
|
+
inSingleQuote = true;
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (char === '"') {
|
|
139
|
+
inDoubleQuote = true;
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (char === '$') {
|
|
145
|
+
const match = rawDdl.slice(i).match(/^\$[A-Za-z0-9_]*\$/);
|
|
146
|
+
if (match) {
|
|
147
|
+
dollarTag = match[0];
|
|
148
|
+
i += dollarTag.length;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check for -- down: marker at beginning of line outside strings/comments
|
|
154
|
+
const isLineStart = i === 0 || rawDdl[i - 1] === '\n';
|
|
155
|
+
if (isLineStart) {
|
|
156
|
+
const lineSlice = rawDdl.slice(i);
|
|
157
|
+
const match = lineSlice.match(/^[ \t]*--\s*down:?[ \t]*(?:\r?\n|$)/i);
|
|
158
|
+
if (match) {
|
|
159
|
+
splitIndex = i;
|
|
160
|
+
matchLength = match[0].length;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (splitIndex === -1) {
|
|
69
169
|
return { upDdl: rawDdl.trim(), downDdl: null };
|
|
70
170
|
}
|
|
71
|
-
|
|
171
|
+
|
|
72
172
|
const upDdl = rawDdl.slice(0, splitIndex).trim();
|
|
73
|
-
const downDdl = rawDdl.slice(splitIndex +
|
|
173
|
+
const downDdl = rawDdl.slice(splitIndex + matchLength).trim();
|
|
74
174
|
return {
|
|
75
175
|
upDdl,
|
|
76
176
|
downDdl: downDdl.length > 0 ? downDdl : null
|
|
@@ -91,6 +191,7 @@ export function splitStatements(ddl: string): string[] {
|
|
|
91
191
|
let inLineComment = false;
|
|
92
192
|
let inBlockComment = false;
|
|
93
193
|
let dollarTag: string | null = null;
|
|
194
|
+
let beginDepth = 0;
|
|
94
195
|
|
|
95
196
|
let i = 0;
|
|
96
197
|
while (i < ddl.length) {
|
|
@@ -207,8 +308,31 @@ export function splitStatements(ddl: string): string[] {
|
|
|
207
308
|
}
|
|
208
309
|
}
|
|
209
310
|
|
|
210
|
-
//
|
|
211
|
-
|
|
311
|
+
// Track BEGIN ... END blocks (e.g. triggers, compound statements)
|
|
312
|
+
const remaining = ddl.slice(i);
|
|
313
|
+
const prevChar = i > 0 ? ddl[i - 1] : ' ';
|
|
314
|
+
const isWordStart = /[\s,;(]/.test(prevChar);
|
|
315
|
+
|
|
316
|
+
if (isWordStart) {
|
|
317
|
+
const beginMatch = remaining.match(/^BEGIN\b/i);
|
|
318
|
+
if (beginMatch) {
|
|
319
|
+
beginDepth++;
|
|
320
|
+
current += beginMatch[0];
|
|
321
|
+
i += beginMatch[0].length;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const endMatch = remaining.match(/^END\b/i);
|
|
326
|
+
if (endMatch) {
|
|
327
|
+
if (beginDepth > 0) beginDepth--;
|
|
328
|
+
current += endMatch[0];
|
|
329
|
+
i += endMatch[0].length;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Statement terminator outside strings or comments, but only when not inside a BEGIN...END block
|
|
335
|
+
if (char === ';' && beginDepth === 0) {
|
|
212
336
|
const trimmed = current.trim();
|
|
213
337
|
if (trimmed.length > 0) {
|
|
214
338
|
statements.push(trimmed);
|
|
@@ -394,14 +518,15 @@ export async function runSliceMigrations(
|
|
|
394
518
|
status: 'APPLIED',
|
|
395
519
|
statementsApplied: pending.length
|
|
396
520
|
});
|
|
397
|
-
} catch (err:
|
|
521
|
+
} catch (err: unknown) {
|
|
398
522
|
hasFailure = true;
|
|
523
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
399
524
|
migrations.push({
|
|
400
525
|
slice: sliceName,
|
|
401
526
|
filePath: path.relative(baseDir, filePath),
|
|
402
527
|
status: 'FAILED',
|
|
403
528
|
statementsApplied: 0,
|
|
404
|
-
error:
|
|
529
|
+
error: errMessage
|
|
405
530
|
});
|
|
406
531
|
}
|
|
407
532
|
}
|
|
@@ -71,6 +71,9 @@ export const sliceSchema = \`
|
|
|
71
71
|
${sqlCols},
|
|
72
72
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
73
73
|
);
|
|
74
|
+
|
|
75
|
+
-- down:
|
|
76
|
+
DROP TABLE IF EXISTS ${tableName};
|
|
74
77
|
\`;
|
|
75
78
|
|
|
76
79
|
// ============================================================================
|
|
@@ -222,6 +225,9 @@ export const sliceSchema = \`
|
|
|
222
225
|
email TEXT NOT NULL UNIQUE,
|
|
223
226
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
224
227
|
);
|
|
228
|
+
|
|
229
|
+
-- down:
|
|
230
|
+
DROP TABLE IF EXISTS ${tableName};
|
|
225
231
|
\`;
|
|
226
232
|
|
|
227
233
|
// ============================================================================
|
|
@@ -513,6 +519,62 @@ export function scaffoldCrud(
|
|
|
513
519
|
return Ok(created);
|
|
514
520
|
}
|
|
515
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Scaffolds a safe shared domain module in `src/shared/<name>.ts`.
|
|
524
|
+
*/
|
|
525
|
+
export function scaffoldShared(name: string, baseDir: string = process.cwd()): Result<string, ScaffoldError> {
|
|
526
|
+
const cleanName = name.replace(/\.ts$/, '');
|
|
527
|
+
const sharedDir = path.resolve(baseDir, 'src', 'shared');
|
|
528
|
+
if (!fs.existsSync(sharedDir)) {
|
|
529
|
+
fs.mkdirSync(sharedDir, { recursive: true });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const targetFile = path.join(sharedDir, `${cleanName}.ts`);
|
|
533
|
+
if (fs.existsSync(targetFile)) {
|
|
534
|
+
return Err({
|
|
535
|
+
code: 'SLICE_EXISTS',
|
|
536
|
+
message: `O módulo compartilhado "${cleanName}.ts" já existe em ${sharedDir}.`,
|
|
537
|
+
candidates: [targetFile]
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const pascalName = toPascalCase(cleanName);
|
|
542
|
+
const content = `import type { DatabaseClient, Result } from 'synapsejs';
|
|
543
|
+
import { Ok, Err } from 'synapsejs';
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Módulo compartilhado para o domínio ${cleanName}.
|
|
547
|
+
* Fatias não importam fatias — regras que envolvem múltiplas fatias ou transações cruzadas
|
|
548
|
+
* residem em src/shared/, recebem o DatabaseClient por parâmetro e abrem db.transaction().
|
|
549
|
+
*/
|
|
550
|
+
export async function executeShared${pascalName}(
|
|
551
|
+
db: DatabaseClient,
|
|
552
|
+
params: Record<string, unknown> = {}
|
|
553
|
+
): Promise<Result<{ success: boolean; data?: unknown }, string>> {
|
|
554
|
+
if (!db) {
|
|
555
|
+
return Err('NO_DATABASE');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return await db.transaction(async (tx) => {
|
|
559
|
+
// Implemente a lógica transacional entre fatias aqui usando tx
|
|
560
|
+
return Ok({ success: true, data: params });
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
`;
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
fs.writeFileSync(targetFile, content, 'utf-8');
|
|
567
|
+
return Ok(targetFile);
|
|
568
|
+
} catch (err: unknown) {
|
|
569
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
570
|
+
return Err({
|
|
571
|
+
code: 'WRITE_FAILED',
|
|
572
|
+
message: `Falha ao escrever módulo compartilhado: ${message}`,
|
|
573
|
+
candidates: [targetFile]
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
516
578
|
if (import.meta.main) {
|
|
517
579
|
const domain = process.argv[2] || 'core';
|
|
518
580
|
const name = process.argv[3] || 'example-feature';
|
|
@@ -96,9 +96,12 @@ export function orderSlicesByDag(sliceFiles: string[]): string[] {
|
|
|
96
96
|
for (const refTable of meta.tablesReferenced) {
|
|
97
97
|
const parentFilePath = tableToSlice.get(refTable);
|
|
98
98
|
if (parentFilePath && parentFilePath !== filePath) {
|
|
99
|
-
// parentFilePath must run before filePath
|
|
100
|
-
graph.get(parentFilePath)
|
|
101
|
-
|
|
99
|
+
// parentFilePath must run before filePath (prevent duplicate edge)
|
|
100
|
+
const neighbors = graph.get(parentFilePath);
|
|
101
|
+
if (neighbors && !neighbors.includes(filePath)) {
|
|
102
|
+
neighbors.push(filePath);
|
|
103
|
+
inDegree.set(filePath, (inDegree.get(filePath) || 0) + 1);
|
|
104
|
+
}
|
|
102
105
|
}
|
|
103
106
|
}
|
|
104
107
|
}
|
|
@@ -82,11 +82,23 @@ const SERVER_ONLY_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
|
|
|
82
82
|
{ label: 'test runner import', pattern: /from\s+['"]bun:test['"]/ },
|
|
83
83
|
{ label: 'process.env access', pattern: /\bprocess\s*(?:\.\s*env|\[\s*['"]env['"]\s*\])/ },
|
|
84
84
|
{ label: 'process destructuring', pattern: /\b(?:const|let|var)\s*\{[^}]*\benv\b[^}]*\}\s*=\s*process\b/ },
|
|
85
|
-
{ label: 'Bun global', pattern: /\bBun
|
|
85
|
+
{ label: 'Bun global', pattern: /\bBun(?:\.|\s*\[)/ },
|
|
86
|
+
{
|
|
87
|
+
label: 'globalThis server access',
|
|
88
|
+
pattern: /\bglobalThis\s*(?:\.\s*(?:process|Bun)|\[\s*['"](?:process|Bun)['"]\s*\])/
|
|
89
|
+
},
|
|
86
90
|
{
|
|
87
91
|
label: 'node server module import',
|
|
88
92
|
pattern: /from\s+['"](?:node:)?(?:fs|child_process|cluster|dgram|dns|net|tls|v8|vm)['"]/
|
|
89
93
|
},
|
|
94
|
+
{
|
|
95
|
+
label: 'server dynamic import',
|
|
96
|
+
pattern: /\bimport\s*\(\s*['"](?:node:)?(?:fs|child_process|sqlite|postgres|bun|cluster|net|tls)['"]/
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
label: 'server require leak',
|
|
100
|
+
pattern: /\brequire\s*\(\s*['"](?:node:)?(?:fs|child_process|sqlite|postgres|bun|cluster|net|tls)['"]/
|
|
101
|
+
},
|
|
90
102
|
{ label: 'dynamic code evaluation', pattern: /\b(?:eval|new\s+Function)\s*\(/ }
|
|
91
103
|
];
|
|
92
104
|
|
|
@@ -603,7 +615,8 @@ export function splitSlice(sliceFilePath: string, baseDir: string = process.cwd(
|
|
|
603
615
|
message:
|
|
604
616
|
`Uma fatia não importa outra: ${path.basename(sliceFilePath)} alcança ` +
|
|
605
617
|
`${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.'
|
|
618
|
+
'src/shared/<nome>.ts, receba o db por parâmetro e chame de dentro das duas fatias. ' +
|
|
619
|
+
'Dica: use `synapse new-shared <nome>` para criar um módulo compartilhado seguro em src/shared/.',
|
|
607
620
|
candidates: [illegalImport]
|
|
608
621
|
});
|
|
609
622
|
}
|
|
@@ -831,8 +844,12 @@ export function verifySplit(result: SplitResult, outDir: string): SplitVerificat
|
|
|
831
844
|
leaks.push('pacote-raiz-no-cliente');
|
|
832
845
|
}
|
|
833
846
|
|
|
847
|
+
const normalizedCode = clientArtifact.code.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) =>
|
|
848
|
+
String.fromCharCode(parseInt(hex, 16))
|
|
849
|
+
);
|
|
850
|
+
|
|
834
851
|
for (const { label, pattern } of SERVER_ONLY_PATTERNS) {
|
|
835
|
-
if (pattern.test(clientArtifact.code)) {
|
|
852
|
+
if (pattern.test(clientArtifact.code) || pattern.test(normalizedCode)) {
|
|
836
853
|
leaks.push(label);
|
|
837
854
|
}
|
|
838
855
|
}
|
|
@@ -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');
|
|
@@ -31,6 +31,43 @@ export type EnqueueFn = <TPayload>(
|
|
|
31
31
|
|
|
32
32
|
export type BroadcastFn = (topic: string, data: unknown) => number;
|
|
33
33
|
|
|
34
|
+
export interface CookieOptions {
|
|
35
|
+
httpOnly?: boolean;
|
|
36
|
+
secure?: boolean;
|
|
37
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
38
|
+
maxAge?: number;
|
|
39
|
+
path?: string;
|
|
40
|
+
domain?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PendingCookie {
|
|
44
|
+
name: string;
|
|
45
|
+
value: string;
|
|
46
|
+
options?: CookieOptions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
|
|
50
|
+
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
51
|
+
if (options.maxAge !== undefined) {
|
|
52
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
53
|
+
}
|
|
54
|
+
if (options.domain && !/[\r\n;\s]/.test(options.domain)) {
|
|
55
|
+
parts.push(`Domain=${options.domain.trim()}`);
|
|
56
|
+
}
|
|
57
|
+
const cleanPath = options.path && !/[\r\n;\s]/.test(options.path) ? options.path.trim() : '/';
|
|
58
|
+
parts.push(`Path=${cleanPath}`);
|
|
59
|
+
if (options.secure) {
|
|
60
|
+
parts.push('Secure');
|
|
61
|
+
}
|
|
62
|
+
if (options.httpOnly) {
|
|
63
|
+
parts.push('HttpOnly');
|
|
64
|
+
}
|
|
65
|
+
if (options.sameSite) {
|
|
66
|
+
parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1).toLowerCase()}`);
|
|
67
|
+
}
|
|
68
|
+
return parts.join('; ');
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
export interface ActionContext<TServices = Record<string, unknown>> extends DatabaseClient {
|
|
35
72
|
/** Underlying Database Client */
|
|
36
73
|
readonly db: DatabaseClient;
|
|
@@ -56,6 +93,15 @@ export interface ActionContext<TServices = Record<string, unknown>> extends Data
|
|
|
56
93
|
/** Broadcasts a real-time event to SSE subscribers */
|
|
57
94
|
readonly broadcast: BroadcastFn;
|
|
58
95
|
|
|
96
|
+
/** Invalidates SSR cache entries by tag or completely */
|
|
97
|
+
readonly invalidateCache: (tags?: string[]) => void;
|
|
98
|
+
|
|
99
|
+
/** Sets an HTTP response cookie (e.g. HttpOnly session token) */
|
|
100
|
+
readonly setCookie: (name: string, value: string, options?: CookieOptions) => void;
|
|
101
|
+
|
|
102
|
+
/** Internal list of pending cookies to be set on the response */
|
|
103
|
+
readonly _pendingCookies?: PendingCookie[];
|
|
104
|
+
|
|
59
105
|
/** Lazy adapter for Kysely (if configured or requested) */
|
|
60
106
|
readonly kysely?: unknown;
|
|
61
107
|
}
|
|
@@ -83,6 +129,7 @@ export interface ActionContextOptions<TServices = Record<string, unknown>> {
|
|
|
83
129
|
enqueue?: EnqueueFn;
|
|
84
130
|
storage?: StorageClient;
|
|
85
131
|
broadcast?: BroadcastFn;
|
|
132
|
+
invalidateCache?: (tags?: string[]) => void;
|
|
86
133
|
kysely?: unknown;
|
|
87
134
|
}
|
|
88
135
|
|
|
@@ -102,9 +149,15 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
102
149
|
enqueue = async () => 'job_noop',
|
|
103
150
|
storage = getStorage(),
|
|
104
151
|
broadcast = (topic: string, data: unknown) => getEventHub().publish(topic, data),
|
|
152
|
+
invalidateCache = () => {},
|
|
105
153
|
kysely
|
|
106
154
|
} = options;
|
|
107
155
|
|
|
156
|
+
const pendingCookies: PendingCookie[] = [];
|
|
157
|
+
const setCookie = (name: string, value: string, options?: CookieOptions) => {
|
|
158
|
+
pendingCookies.push({ name, value, options });
|
|
159
|
+
};
|
|
160
|
+
|
|
108
161
|
const context: ActionContext<TServices> = {
|
|
109
162
|
// ActionContext properties
|
|
110
163
|
db,
|
|
@@ -115,6 +168,9 @@ export function createActionContext<TServices = Record<string, unknown>>(
|
|
|
115
168
|
enqueue,
|
|
116
169
|
storage,
|
|
117
170
|
broadcast,
|
|
171
|
+
invalidateCache,
|
|
172
|
+
setCookie,
|
|
173
|
+
_pendingCookies: pendingCookies,
|
|
118
174
|
kysely,
|
|
119
175
|
|
|
120
176
|
// DatabaseClient proxy methods
|
|
@@ -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 | void;
|
|
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,42 @@ 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
|
+
*/
|
|
59
|
+
trustProxy?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Max allowed payload bytes for RPC POST requests (default: 5MB).
|
|
63
|
+
*/
|
|
64
|
+
maxRpcPayloadBytes?: number;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Max allowed payload bytes for Webhook POST requests (default: 10MB).
|
|
68
|
+
*/
|
|
69
|
+
maxWebhookPayloadBytes?: number;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* SSR Cache settings.
|
|
73
|
+
*/
|
|
74
|
+
cache?: {
|
|
75
|
+
maxEntries?: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Image optimizer security settings.
|
|
80
|
+
*/
|
|
81
|
+
imageOptimizer?: {
|
|
82
|
+
allowedDomains?: string[];
|
|
83
|
+
};
|
|
48
84
|
}
|
|
49
85
|
|
|
50
86
|
export function defineConfig<TServices = Record<string, unknown>>(
|
package/src/core/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './action-context';
|
|
2
2
|
export * from './action-definition';
|
|
3
|
+
export * from './cache';
|
|
3
4
|
export * from './config';
|
|
4
5
|
export * from './database-client';
|
|
5
6
|
export * from './database-factory';
|
|
@@ -14,3 +15,4 @@ export * from './session-token';
|
|
|
14
15
|
export * from './sqlite-client';
|
|
15
16
|
export * from './storage';
|
|
16
17
|
export * from './validation';
|
|
18
|
+
export * from './websocket';
|