@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.
Files changed (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. package/templates/starter/package.json +2 -2
@@ -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
- * Domain error codes are the contract; the HTTP status is a transport detail
124
- * derived from them. Before this, every failure was 400, so a client could not
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
- curRoot.innerHTML = newRoot.innerHTML;
161
- document.title = doc.title;
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, '&amp;')
219
- .replace(/</g, '&lt;')
220
- .replace(/>/g, '&gt;')
221
- .replace(/"/g, '&quot;')
222
- .replace(/'/g, '&#39;');
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 params = Object.fromEntries(url.searchParams.entries());
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 rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
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.getElementById('__SYNAPSE_PROPS_DATA__').textContent || '{}');</script>
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.getElementById('__SYNAPSE_PROPS_DATA__').textContent || '{}');</script>
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 (!fs.existsSync(bundleFile) && bundleFilename === VENDOR_BUNDLE_NAME) {
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': 'application/javascript; charset=utf-8' }
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
- const strippedPath = pathname.replace(I18N_LOCALE_REGEX, '/');
2564
- for (const slice of this.slices.values()) {
2565
- if (strippedPath === slice.routePath) {
2566
- resolvedSlice = slice;
2567
- matchedLocale = localeMatch[1];
2568
- break;
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.1.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": "^5.8.2"
33
+ "typescript": "~5.9.3"
34
34
  }
35
35
  }