@ismaelsoilet/synapsejs 1.9.0 → 1.11.1
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 +4 -3
- package/bin/synapse.ts +74 -2
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +2 -0
- package/src/compiler/contract.ts +1 -1
- package/src/compiler/oracle-runner.ts +10 -1
- package/src/compiler/scaffolder.ts +113 -4
- package/src/compiler/slice-splitter.ts +21 -3
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +50 -1
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +35 -0
- package/src/core/index.ts +2 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +51 -2
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +16 -1
- package/src/core/sqlite-client.ts +20 -0
- package/src/mcp/server.ts +257 -1
- package/src/mcp/tools.ts +92 -1
- package/src/project-status.ts +103 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/queue-engine.ts +120 -5
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +300 -58
- package/templates/starter/package.json +2 -2
package/src/runtime/server.ts
CHANGED
|
@@ -13,8 +13,10 @@ import * as path from 'path';
|
|
|
13
13
|
import React from 'react';
|
|
14
14
|
import { renderToString } from 'react-dom/server';
|
|
15
15
|
import { SynapseProvider } from '../client/context';
|
|
16
|
+
import { ToastProvider } from '../client/toast';
|
|
16
17
|
import {
|
|
17
18
|
buildClientBundle,
|
|
19
|
+
buildCssBundle,
|
|
18
20
|
buildVendorBundle,
|
|
19
21
|
type ClientManifest,
|
|
20
22
|
freshBundleFrom,
|
|
@@ -27,21 +29,32 @@ import {
|
|
|
27
29
|
type ActionContext,
|
|
28
30
|
AnonymousSession,
|
|
29
31
|
BoundedLruCache,
|
|
32
|
+
buildOAuthAuthorizationUrl,
|
|
30
33
|
createActionContext,
|
|
31
34
|
createDefaultLogger,
|
|
32
35
|
createSession,
|
|
33
36
|
type DatabaseClient,
|
|
37
|
+
discordOAuthPreset,
|
|
38
|
+
exchangeOAuthCode,
|
|
39
|
+
fetchOAuthUserProfile,
|
|
40
|
+
generateOAuthState,
|
|
41
|
+
generatePkceChallenge,
|
|
34
42
|
getDatabase,
|
|
43
|
+
githubOAuthPreset,
|
|
44
|
+
googleOAuthPreset,
|
|
35
45
|
hasAnyRole,
|
|
36
46
|
loadSynapseConfig,
|
|
37
47
|
normalizeCacheKey,
|
|
48
|
+
type OAuthProviderConfig,
|
|
38
49
|
optimizeImage,
|
|
39
50
|
resetDatabaseInstance,
|
|
40
51
|
type SessionContext,
|
|
41
52
|
type SliceCacheConfig,
|
|
42
53
|
type SliceSocketDefinition,
|
|
43
54
|
type SynapseConfig,
|
|
44
|
-
serializeCookie
|
|
55
|
+
serializeCookie,
|
|
56
|
+
signSessionToken,
|
|
57
|
+
verifyOAuthState
|
|
45
58
|
} from '../core/index';
|
|
46
59
|
import { ROLES_COOKIE, SESSION_COOKIE } from '../core/session-cookie';
|
|
47
60
|
import { isSessionTokenRevoked, isSessionTokenRevokedInDb, verifySessionToken } from '../core/session-token';
|
|
@@ -119,21 +132,9 @@ export interface SliceLoadError {
|
|
|
119
132
|
message: string;
|
|
120
133
|
}
|
|
121
134
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
* tell an expired session (401) from a missing row (404) or a duplicate (409).
|
|
126
|
-
* Unknown codes keep 400: the domain, not the transport, decides.
|
|
127
|
-
*/
|
|
128
|
-
const ERROR_STATUS: Array<{ pattern: RegExp; status: number }> = [
|
|
129
|
-
{ pattern: /^UNAUTHORIZED$/, status: 401 },
|
|
130
|
-
{ pattern: /^FORBIDDEN$/, status: 403 },
|
|
131
|
-
{ pattern: /_NOT_FOUND$/, status: 404 },
|
|
132
|
-
{ pattern: /^(DUPLICATE_|ALREADY_|CONFLICT)/, status: 409 },
|
|
133
|
-
{ pattern: /^(INVALID_|MALFORMED)/, status: 422 },
|
|
134
|
-
{ pattern: /^(NO_DATABASE|PERSISTENCE_FAILED)/, status: 500 },
|
|
135
|
-
{ pattern: /_FAILED$/, status: 500 }
|
|
136
|
-
];
|
|
135
|
+
import { ERROR_STATUS, escapeHtml, httpStatusForError } from './security-headers';
|
|
136
|
+
|
|
137
|
+
export { ERROR_STATUS, escapeHtml, httpStatusForError };
|
|
137
138
|
|
|
138
139
|
const RATE_LIMITED_PREFIXES = [
|
|
139
140
|
'/_synapse/rpc/',
|
|
@@ -157,8 +158,58 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
|
|
|
157
158
|
const newRoot = doc.querySelector('#synapse-root');
|
|
158
159
|
const curRoot = document.querySelector('#synapse-root');
|
|
159
160
|
if (newRoot && curRoot) {
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
const updateDom = function() {
|
|
162
|
+
var preserved = document.querySelectorAll('[data-synapse-preserve]');
|
|
163
|
+
var preservedMap = new Map();
|
|
164
|
+
|
|
165
|
+
preserved.forEach(function(el) {
|
|
166
|
+
var key = el.getAttribute('data-synapse-preserve') || el.id || el.getAttribute('name');
|
|
167
|
+
if (!key) return;
|
|
168
|
+
preservedMap.set(key, {
|
|
169
|
+
value: el.value,
|
|
170
|
+
checked: el.checked,
|
|
171
|
+
selectedIndex: el.selectedIndex,
|
|
172
|
+
scrollTop: el.scrollTop,
|
|
173
|
+
innerHTML: el.innerHTML,
|
|
174
|
+
focused: document.activeElement === el
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
curRoot.innerHTML = newRoot.innerHTML;
|
|
179
|
+
document.title = doc.title;
|
|
180
|
+
|
|
181
|
+
var findTarget = function(key) {
|
|
182
|
+
var candidates = document.querySelectorAll('[data-synapse-preserve], [id], [name]');
|
|
183
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
184
|
+
var candidate = candidates[i];
|
|
185
|
+
var candidateKey =
|
|
186
|
+
candidate.getAttribute('data-synapse-preserve') || candidate.id || candidate.getAttribute('name');
|
|
187
|
+
if (candidateKey === key) return candidate;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
preservedMap.forEach(function(state, key) {
|
|
193
|
+
var target = findTarget(key);
|
|
194
|
+
if (!target) return;
|
|
195
|
+
|
|
196
|
+
if (target.value !== undefined && state.value !== undefined) target.value = state.value;
|
|
197
|
+
if (target.checked !== undefined && state.checked !== undefined) target.checked = state.checked;
|
|
198
|
+
if (target.selectedIndex !== undefined && state.selectedIndex !== undefined) {
|
|
199
|
+
target.selectedIndex = state.selectedIndex;
|
|
200
|
+
}
|
|
201
|
+
if (state.scrollTop !== undefined) target.scrollTop = state.scrollTop;
|
|
202
|
+
if (target.value === undefined && state.innerHTML !== undefined) target.innerHTML = state.innerHTML;
|
|
203
|
+
if (state.focused && typeof target.focus === 'function') target.focus();
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (document.startViewTransition) {
|
|
208
|
+
await document.startViewTransition(updateDom).finished;
|
|
209
|
+
} else {
|
|
210
|
+
updateDom();
|
|
211
|
+
}
|
|
212
|
+
|
|
162
213
|
var newPropsScript = doc.querySelector('script[data-synapse-props]');
|
|
163
214
|
if (newPropsScript && newPropsScript.textContent) {
|
|
164
215
|
try {
|
|
@@ -167,6 +218,7 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
|
|
|
167
218
|
}
|
|
168
219
|
if (push) history.pushState({}, '', href);
|
|
169
220
|
window.dispatchEvent(new CustomEvent('synapse:morphed', { detail: { url: href } }));
|
|
221
|
+
window.__SYNAPSE_URL__ = href;
|
|
170
222
|
var newClientScript = doc.querySelector('script[data-synapse-client]');
|
|
171
223
|
if (newClientScript && newClientScript.src) {
|
|
172
224
|
import(newClientScript.src).then(function(mod) {
|
|
@@ -200,28 +252,6 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
|
|
|
200
252
|
});
|
|
201
253
|
})();`;
|
|
202
254
|
|
|
203
|
-
export function httpStatusForError(error: unknown): number {
|
|
204
|
-
const code = typeof error === 'string' ? error : '';
|
|
205
|
-
|
|
206
|
-
for (const entry of ERROR_STATUS) {
|
|
207
|
-
if (entry.pattern.test(code)) {
|
|
208
|
-
return entry.status;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return 400;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Escapes special HTML characters to prevent Reflected/Stored XSS. */
|
|
216
|
-
export function escapeHtml(str: string): string {
|
|
217
|
-
return str
|
|
218
|
-
.replace(/&/g, '&')
|
|
219
|
-
.replace(/</g, '<')
|
|
220
|
-
.replace(/>/g, '>')
|
|
221
|
-
.replace(/"/g, '"')
|
|
222
|
-
.replace(/'/g, ''');
|
|
223
|
-
}
|
|
224
|
-
|
|
225
255
|
/** Parses Cookie request header safely into key-value pairs. */
|
|
226
256
|
export function parseCookies(header: string | null | undefined): Record<string, string> {
|
|
227
257
|
if (!header) {
|
|
@@ -988,6 +1018,7 @@ export class SynapseServer {
|
|
|
988
1018
|
userId: verified.value.userId,
|
|
989
1019
|
tenantId: verified.value.tenantId,
|
|
990
1020
|
roles: verified.value.roles,
|
|
1021
|
+
permissions: verified.value.permissions,
|
|
991
1022
|
token
|
|
992
1023
|
})
|
|
993
1024
|
: AnonymousSession();
|
|
@@ -1337,6 +1368,11 @@ export class SynapseServer {
|
|
|
1337
1368
|
return ' <link rel="stylesheet" href="/synapse.css">';
|
|
1338
1369
|
}
|
|
1339
1370
|
|
|
1371
|
+
const compiledCssPath = path.join(this.baseDir, '.synapse', 'client', 'synapse.css');
|
|
1372
|
+
if (fs.existsSync(compiledCssPath)) {
|
|
1373
|
+
return ' <link rel="stylesheet" href="/_synapse/client/synapse.css">';
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1340
1376
|
const thirdParty = this.thirdPartyOrigins();
|
|
1341
1377
|
|
|
1342
1378
|
if (!thirdParty.enabled) {
|
|
@@ -1366,6 +1402,166 @@ export class SynapseServer {
|
|
|
1366
1402
|
return elements.join('\n');
|
|
1367
1403
|
}
|
|
1368
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* Unified Social & Enterprise OAuth 2.0 Handler (RFC 7636 PKCE + Constant-Time State Verification)
|
|
1407
|
+
*/
|
|
1408
|
+
private async handleOAuthRequest(
|
|
1409
|
+
req: Request,
|
|
1410
|
+
providerName: string,
|
|
1411
|
+
isCallback: boolean,
|
|
1412
|
+
url: URL,
|
|
1413
|
+
startedAt: number
|
|
1414
|
+
): Promise<Response> {
|
|
1415
|
+
const cors = this.corsHeaders(req);
|
|
1416
|
+
const validProviders = ['google', 'github', 'discord'];
|
|
1417
|
+
|
|
1418
|
+
if (!validProviders.includes(providerName)) {
|
|
1419
|
+
this.logRequest(req, url.pathname, 404, startedAt);
|
|
1420
|
+
return Response.json(
|
|
1421
|
+
{ ok: false, error: `Provedor OAuth "${providerName}" não suportado. Aceitos: ${validProviders.join(', ')}` },
|
|
1422
|
+
{ status: 404, headers: cors ?? undefined }
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const redirectUri = `${url.origin}/_synapse/auth/${providerName}/callback`;
|
|
1427
|
+
const clientIdEnv = process.env[`${providerName.toUpperCase()}_CLIENT_ID`] || '';
|
|
1428
|
+
const clientSecretEnv = process.env[`${providerName.toUpperCase()}_CLIENT_SECRET`] || '';
|
|
1429
|
+
|
|
1430
|
+
if (!clientIdEnv) {
|
|
1431
|
+
this.logRequest(req, url.pathname, 500, startedAt);
|
|
1432
|
+
return Response.json(
|
|
1433
|
+
{
|
|
1434
|
+
ok: false,
|
|
1435
|
+
error: `Variável de ambiente ${providerName.toUpperCase()}_CLIENT_ID não configurada no servidor.`
|
|
1436
|
+
},
|
|
1437
|
+
{ status: 500, headers: cors ?? undefined }
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
let config: OAuthProviderConfig;
|
|
1442
|
+
if (providerName === 'google') {
|
|
1443
|
+
config = googleOAuthPreset({ clientId: clientIdEnv, clientSecret: clientSecretEnv, redirectUri });
|
|
1444
|
+
} else if (providerName === 'github') {
|
|
1445
|
+
config = githubOAuthPreset({ clientId: clientIdEnv, clientSecret: clientSecretEnv, redirectUri });
|
|
1446
|
+
} else {
|
|
1447
|
+
config = discordOAuthPreset({ clientId: clientIdEnv, clientSecret: clientSecretEnv, redirectUri });
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// 1. INICIAR FLUXO: GET /_synapse/auth/:provider
|
|
1451
|
+
if (!isCallback) {
|
|
1452
|
+
const pkce = generatePkceChallenge();
|
|
1453
|
+
const state = generateOAuthState();
|
|
1454
|
+
const authUrl = buildOAuthAuthorizationUrl({
|
|
1455
|
+
config,
|
|
1456
|
+
state,
|
|
1457
|
+
codeChallenge: pkce.codeChallenge
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
const statePayload = JSON.stringify({ state, codeVerifier: pkce.codeVerifier, provider: providerName });
|
|
1461
|
+
const stateCookie = serializeCookie('__synapse_oauth_state', statePayload, {
|
|
1462
|
+
httpOnly: true,
|
|
1463
|
+
secure: !url.hostname.includes('localhost') && !url.hostname.includes('127.0.0.1'),
|
|
1464
|
+
sameSite: 'lax',
|
|
1465
|
+
maxAge: 600,
|
|
1466
|
+
path: '/'
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
this.logRequest(req, url.pathname, 302, startedAt);
|
|
1470
|
+
return new Response(null, {
|
|
1471
|
+
status: 302,
|
|
1472
|
+
headers: {
|
|
1473
|
+
Location: authUrl,
|
|
1474
|
+
'Set-Cookie': stateCookie,
|
|
1475
|
+
...(cors ?? {})
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// 2. CALLBACK: GET /_synapse/auth/:provider/callback
|
|
1481
|
+
const code = url.searchParams.get('code');
|
|
1482
|
+
const receivedState = url.searchParams.get('state');
|
|
1483
|
+
|
|
1484
|
+
const cookieHeader = req.headers.get('cookie') || '';
|
|
1485
|
+
const match = cookieHeader.match(/__synapse_oauth_state=([^;]+)/);
|
|
1486
|
+
let cookieStateData: { state: string; codeVerifier: string; provider: string } | null = null;
|
|
1487
|
+
|
|
1488
|
+
if (match) {
|
|
1489
|
+
try {
|
|
1490
|
+
cookieStateData = JSON.parse(decodeURIComponent(match[1]));
|
|
1491
|
+
} catch {}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
if (!code || !receivedState || !cookieStateData || !verifyOAuthState(cookieStateData.state, receivedState)) {
|
|
1495
|
+
this.logRequest(req, url.pathname, 400, startedAt);
|
|
1496
|
+
return Response.json(
|
|
1497
|
+
{ ok: false, error: 'INVALID_OAUTH_STATE', message: 'Parâmetro de estado OAuth inválido ou expirado.' },
|
|
1498
|
+
{ status: 400, headers: cors ?? undefined }
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const exchangeRes = await exchangeOAuthCode({
|
|
1503
|
+
config,
|
|
1504
|
+
code,
|
|
1505
|
+
codeVerifier: cookieStateData.codeVerifier,
|
|
1506
|
+
timeoutMs: 10_000
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
if (!exchangeRes.ok) {
|
|
1510
|
+
this.logRequest(req, url.pathname, 502, startedAt);
|
|
1511
|
+
return Response.json(
|
|
1512
|
+
{ ok: false, error: 'OAUTH_EXCHANGE_FAILED', message: 'Falha ao trocar código de autorização por token.' },
|
|
1513
|
+
{ status: 502, headers: cors ?? undefined }
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const profileRes = await fetchOAuthUserProfile(
|
|
1518
|
+
providerName as 'google' | 'github' | 'discord',
|
|
1519
|
+
exchangeRes.value.accessToken
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
if (!profileRes.ok) {
|
|
1523
|
+
this.logRequest(req, url.pathname, 502, startedAt);
|
|
1524
|
+
return Response.json(
|
|
1525
|
+
{ ok: false, error: 'OAUTH_PROFILE_FAILED', message: 'Falha ao recuperar perfil do usuário no provedor.' },
|
|
1526
|
+
{ status: 502, headers: cors ?? undefined }
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const profile = profileRes.value;
|
|
1531
|
+
const secret = process.env.SYNAPSE_SESSION_SECRET || 'dev_secret_insecure_change_in_production';
|
|
1532
|
+
const sessionToken = signSessionToken(
|
|
1533
|
+
{
|
|
1534
|
+
userId: `${providerName}:${profile.id}`,
|
|
1535
|
+
roles: ['user'],
|
|
1536
|
+
permissions: ['read', 'write']
|
|
1537
|
+
},
|
|
1538
|
+
secret
|
|
1539
|
+
);
|
|
1540
|
+
|
|
1541
|
+
const sessionCookieHeader = serializeCookie(SESSION_COOKIE, sessionToken, {
|
|
1542
|
+
httpOnly: true,
|
|
1543
|
+
secure: !url.hostname.includes('localhost') && !url.hostname.includes('127.0.0.1'),
|
|
1544
|
+
sameSite: 'lax',
|
|
1545
|
+
maxAge: 86400,
|
|
1546
|
+
path: '/'
|
|
1547
|
+
});
|
|
1548
|
+
const clearStateCookie = serializeCookie('__synapse_oauth_state', '', {
|
|
1549
|
+
httpOnly: true,
|
|
1550
|
+
maxAge: 0,
|
|
1551
|
+
path: '/'
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
this.logRequest(req, url.pathname, 302, startedAt);
|
|
1555
|
+
return new Response(null, {
|
|
1556
|
+
status: 302,
|
|
1557
|
+
headers: {
|
|
1558
|
+
Location: '/',
|
|
1559
|
+
'Set-Cookie': `${sessionCookieHeader}, ${clearStateCookie}`,
|
|
1560
|
+
...(cors ?? {})
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1369
1565
|
/**
|
|
1370
1566
|
* Renders the slice SSR component tree and packages the complete HTML shell
|
|
1371
1567
|
*/
|
|
@@ -1373,7 +1569,8 @@ export class SynapseServer {
|
|
|
1373
1569
|
slice: DiscoveredSlice,
|
|
1374
1570
|
req: Request,
|
|
1375
1571
|
url: URL,
|
|
1376
|
-
matchedLocale?: string
|
|
1572
|
+
matchedLocale?: string,
|
|
1573
|
+
routeParams: Record<string, string> = {}
|
|
1377
1574
|
): Promise<{
|
|
1378
1575
|
html: string;
|
|
1379
1576
|
dataProps: Record<string, unknown>;
|
|
@@ -1383,9 +1580,11 @@ export class SynapseServer {
|
|
|
1383
1580
|
}> {
|
|
1384
1581
|
const session = await this.requestSession(req);
|
|
1385
1582
|
const actionCtx = this.createActionContext(slice.key, session);
|
|
1386
|
-
const
|
|
1583
|
+
const searchParams = Object.fromEntries(url.searchParams.entries());
|
|
1584
|
+
const params = { ...searchParams, ...routeParams };
|
|
1387
1585
|
const dataProps: Record<string, unknown> = {
|
|
1388
1586
|
...params,
|
|
1587
|
+
__synapse_params: routeParams,
|
|
1389
1588
|
...(matchedLocale ? { locale: matchedLocale } : {})
|
|
1390
1589
|
};
|
|
1391
1590
|
let loaderError: string | null = null;
|
|
@@ -1449,7 +1648,12 @@ export class SynapseServer {
|
|
|
1449
1648
|
const treeWithLayout = this.layoutComponent
|
|
1450
1649
|
? React.createElement(this.layoutComponent, { session, url: url.toString() }, treeWithDomainLayout)
|
|
1451
1650
|
: treeWithDomainLayout;
|
|
1452
|
-
const
|
|
1651
|
+
const treeWithToast = React.createElement(ToastProvider, null, treeWithLayout);
|
|
1652
|
+
const rootEl = React.createElement(
|
|
1653
|
+
SynapseProvider,
|
|
1654
|
+
{ props: dataProps, session, url: url.toString() },
|
|
1655
|
+
treeWithToast
|
|
1656
|
+
);
|
|
1453
1657
|
contentHtml = renderToString(rootEl);
|
|
1454
1658
|
} catch (e: any) {
|
|
1455
1659
|
hasError = true;
|
|
@@ -1474,7 +1678,9 @@ export class SynapseServer {
|
|
|
1474
1678
|
clientUrl,
|
|
1475
1679
|
meta,
|
|
1476
1680
|
matchedLocale || 'pt-BR',
|
|
1477
|
-
hasCustomLayout
|
|
1681
|
+
hasCustomLayout,
|
|
1682
|
+
routeParams,
|
|
1683
|
+
url.toString()
|
|
1478
1684
|
);
|
|
1479
1685
|
|
|
1480
1686
|
return { html, dataProps, meta, hasError, error: renderError || undefined };
|
|
@@ -1491,7 +1697,9 @@ export class SynapseServer {
|
|
|
1491
1697
|
clientUrl: string | null,
|
|
1492
1698
|
meta?: SliceMetadata,
|
|
1493
1699
|
locale = 'pt-BR',
|
|
1494
|
-
hasLayout = false
|
|
1700
|
+
hasLayout = false,
|
|
1701
|
+
routeParams: Record<string, string> = {},
|
|
1702
|
+
currentUrl = '/'
|
|
1495
1703
|
): string {
|
|
1496
1704
|
const safeTitle = escapeHtml(title);
|
|
1497
1705
|
const safeSliceName = escapeHtml(sliceName);
|
|
@@ -1559,7 +1767,7 @@ ${importMapScript}
|
|
|
1559
1767
|
<div id="synapse-root" class="min-h-full flex-1 flex flex-col">${contentHtml}</div>
|
|
1560
1768
|
|
|
1561
1769
|
<script data-synapse-props id="__SYNAPSE_PROPS_DATA__" type="application/json">${propsJson}</script>
|
|
1562
|
-
<script>globalThis.__SYNAPSE_PROPS__ = JSON.parse(document.
|
|
1770
|
+
<script>globalThis.__SYNAPSE_PROPS__ = JSON.parse((document.querySelector('script[data-synapse-props][id="__SYNAPSE_PROPS_DATA__"]') || {}).textContent || '{}'); globalThis.__SYNAPSE_ROUTE_PARAMS__ = ${JSON.stringify(routeParams)}; globalThis.__SYNAPSE_URL__ = ${JSON.stringify(currentUrl)};</script>
|
|
1563
1771
|
${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
|
|
1564
1772
|
|
|
1565
1773
|
<script type="module" src="/_synapse/turbo-router.js"></script>
|
|
@@ -1616,7 +1824,7 @@ ${importMapScript}
|
|
|
1616
1824
|
|
|
1617
1825
|
<!-- Props de hidratacao e a entrada de cliente gerada -->
|
|
1618
1826
|
<script data-synapse-props id="__SYNAPSE_PROPS_DATA__" type="application/json">${propsJson}</script>
|
|
1619
|
-
<script>globalThis.__SYNAPSE_PROPS__ = JSON.parse(document.
|
|
1827
|
+
<script>globalThis.__SYNAPSE_PROPS__ = JSON.parse((document.querySelector('script[data-synapse-props][id="__SYNAPSE_PROPS_DATA__"]') || {}).textContent || '{}'); globalThis.__SYNAPSE_ROUTE_PARAMS__ = ${JSON.stringify(routeParams)}; globalThis.__SYNAPSE_URL__ = ${JSON.stringify(currentUrl)};</script>
|
|
1620
1828
|
${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
|
|
1621
1829
|
|
|
1622
1830
|
<!-- Turbo Morphing SPA Navigation (<1.5kb, Zero Dependencies) -->
|
|
@@ -2043,6 +2251,17 @@ ${this.renderStylesheets()}
|
|
|
2043
2251
|
});
|
|
2044
2252
|
}
|
|
2045
2253
|
|
|
2254
|
+
// Unified Social & Enterprise OAuth 2.0 Dispatcher: /_synapse/auth/:provider and /_synapse/auth/:provider/callback
|
|
2255
|
+
if (pathname.startsWith('/_synapse/auth/')) {
|
|
2256
|
+
const authPath = pathname.slice('/_synapse/auth/'.length);
|
|
2257
|
+
const isCallback = authPath.endsWith('/callback');
|
|
2258
|
+
const providerName = isCallback
|
|
2259
|
+
? authPath.slice(0, -'/callback'.length).toLowerCase()
|
|
2260
|
+
: authPath.toLowerCase();
|
|
2261
|
+
|
|
2262
|
+
return await this.handleOAuthRequest(req, providerName, isCallback, url, startedAt);
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2046
2265
|
// 3. RPC Actions Dispatcher (POST /_synapse/rpc/:sliceName)
|
|
2047
2266
|
if (pathname.startsWith('/_synapse/rpc/')) {
|
|
2048
2267
|
const cors = this.corsHeaders(req);
|
|
@@ -2533,22 +2752,29 @@ ${this.renderStylesheets()}
|
|
|
2533
2752
|
if (pathname.startsWith('/_synapse/client/')) {
|
|
2534
2753
|
const bundleFilename = path.basename(pathname);
|
|
2535
2754
|
const bundleFile = path.join(this.baseDir, '.synapse/client', bundleFilename);
|
|
2536
|
-
if (
|
|
2755
|
+
if (bundleFilename === VENDOR_BUNDLE_NAME) {
|
|
2537
2756
|
await buildVendorBundle(this.baseDir, this.config.compiler?.vendorPackages);
|
|
2538
2757
|
}
|
|
2758
|
+
if (!fs.existsSync(bundleFile) && bundleFilename === 'synapse.css') {
|
|
2759
|
+
await buildCssBundle(this.baseDir);
|
|
2760
|
+
}
|
|
2539
2761
|
if (!fs.existsSync(bundleFile)) {
|
|
2540
2762
|
this.logRequest(req, pathname, 404, startedAt);
|
|
2541
2763
|
return new Response('Client bundle not found', { status: 404 });
|
|
2542
2764
|
}
|
|
2543
2765
|
this.logRequest(req, pathname, 200, startedAt, undefined, 'static');
|
|
2766
|
+
const contentType = bundleFilename.endsWith('.css')
|
|
2767
|
+
? 'text/css; charset=utf-8'
|
|
2768
|
+
: 'application/javascript; charset=utf-8';
|
|
2544
2769
|
return new Response(Bun.file(bundleFile), {
|
|
2545
|
-
headers: { 'Content-Type':
|
|
2770
|
+
headers: { 'Content-Type': contentType }
|
|
2546
2771
|
});
|
|
2547
2772
|
}
|
|
2548
2773
|
|
|
2549
|
-
// 5. UI Route Dispatcher (GET /:domain/:sliceName with optional i18n locale prefix)
|
|
2774
|
+
// 5. UI Route Dispatcher (GET /:domain/:sliceName with optional i18n locale prefix or dynamic path segments)
|
|
2550
2775
|
let resolvedSlice: DiscoveredSlice | undefined;
|
|
2551
2776
|
let matchedLocale: string | undefined;
|
|
2777
|
+
const routeParams: Record<string, string> = {};
|
|
2552
2778
|
|
|
2553
2779
|
for (const slice of this.slices.values()) {
|
|
2554
2780
|
if (pathname === slice.routePath) {
|
|
@@ -2559,14 +2785,30 @@ ${this.renderStylesheets()}
|
|
|
2559
2785
|
|
|
2560
2786
|
if (!resolvedSlice && !pathname.startsWith('/_synapse/')) {
|
|
2561
2787
|
const localeMatch = pathname.match(I18N_LOCALE_REGEX);
|
|
2788
|
+
const strippedPath = localeMatch ? pathname.replace(I18N_LOCALE_REGEX, '/') : pathname;
|
|
2562
2789
|
if (localeMatch) {
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2790
|
+
matchedLocale = localeMatch[1];
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
for (const slice of this.slices.values()) {
|
|
2794
|
+
if (strippedPath === slice.routePath) {
|
|
2795
|
+
resolvedSlice = slice;
|
|
2796
|
+
break;
|
|
2797
|
+
}
|
|
2798
|
+
// Suporte a segmentos dinâmicos de rota: /:domain/:sliceName/:param...
|
|
2799
|
+
if (strippedPath.startsWith(`${slice.routePath}/`)) {
|
|
2800
|
+
resolvedSlice = slice;
|
|
2801
|
+
const remainingSegments = strippedPath
|
|
2802
|
+
.slice(slice.routePath.length + 1)
|
|
2803
|
+
.split('/')
|
|
2804
|
+
.filter(Boolean);
|
|
2805
|
+
if (remainingSegments.length > 0) {
|
|
2806
|
+
routeParams.id = remainingSegments[0];
|
|
2807
|
+
for (let i = 0; i < remainingSegments.length; i++) {
|
|
2808
|
+
routeParams[`param${i + 1}`] = remainingSegments[i];
|
|
2809
|
+
}
|
|
2569
2810
|
}
|
|
2811
|
+
break;
|
|
2570
2812
|
}
|
|
2571
2813
|
}
|
|
2572
2814
|
}
|
|
@@ -2611,7 +2853,7 @@ ${this.renderStylesheets()}
|
|
|
2611
2853
|
this.revalidatingKeys.add(cacheKey);
|
|
2612
2854
|
(async () => {
|
|
2613
2855
|
try {
|
|
2614
|
-
const fresh = await this.renderSliceHtml(slice, req, url, matchedLocale);
|
|
2856
|
+
const fresh = await this.renderSliceHtml(slice, req, url, matchedLocale, routeParams);
|
|
2615
2857
|
if (!fresh.hasError) {
|
|
2616
2858
|
const ttl = cacheConfig.ttlSeconds;
|
|
2617
2859
|
const swr = cacheConfig.staleWhileRevalidateSeconds ?? 0;
|
|
@@ -2643,7 +2885,7 @@ ${this.renderStylesheets()}
|
|
|
2643
2885
|
}
|
|
2644
2886
|
}
|
|
2645
2887
|
|
|
2646
|
-
const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale);
|
|
2888
|
+
const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale, routeParams);
|
|
2647
2889
|
|
|
2648
2890
|
if (hasError) {
|
|
2649
2891
|
this.logRequest(req, pathname, 500, startedAt, session, 'ssr');
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"start": "synapse start"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ismaelsoilet/synapsejs": "^1.
|
|
23
|
+
"@ismaelsoilet/synapsejs": "^1.11.1",
|
|
24
24
|
"@sinclair/typebox": "^0.34.40",
|
|
25
25
|
"fast-check": "^3.23.2",
|
|
26
26
|
"react": "^19.0.0",
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
"@types/bun": "^1.4.2",
|
|
31
31
|
"@types/react": "^19.0.10",
|
|
32
32
|
"@types/react-dom": "^19.0.4",
|
|
33
|
-
"typescript": "
|
|
33
|
+
"typescript": "~5.9.3"
|
|
34
34
|
}
|
|
35
35
|
}
|