@ismaelsoilet/synapsejs 1.3.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.
Files changed (38) hide show
  1. package/README.md +15 -8
  2. package/bin/synapse.ts +70 -3
  3. package/package.json +1 -1
  4. package/src/client/components.tsx +26 -9
  5. package/src/client/outlet.tsx +40 -0
  6. package/src/client/sub-slice.tsx +45 -0
  7. package/src/client/use-websocket.ts +146 -0
  8. package/src/client.ts +12 -0
  9. package/src/compiler/client-bundler.ts +17 -7
  10. package/src/compiler/migration-runner.ts +134 -9
  11. package/src/compiler/scaffolder.ts +62 -0
  12. package/src/compiler/schema-dag.ts +18 -11
  13. package/src/compiler/slice-splitter.ts +29 -4
  14. package/src/compiler/slice-templates.ts +10 -0
  15. package/src/compiler/standalone-builder.ts +16 -2
  16. package/src/core/action-context.ts +56 -0
  17. package/src/core/action-definition.ts +89 -0
  18. package/src/core/cache.ts +139 -0
  19. package/src/core/config.ts +37 -1
  20. package/src/core/index.ts +3 -0
  21. package/src/core/postgres-client.ts +6 -0
  22. package/src/core/postgres-queue.ts +27 -7
  23. package/src/core/query-builder.ts +75 -60
  24. package/src/core/session-context.ts +1 -1
  25. package/src/core/session-token.ts +7 -1
  26. package/src/core/sqlite-client.ts +26 -1
  27. package/src/core/storage.ts +1 -5
  28. package/src/core/websocket.ts +30 -0
  29. package/src/index.ts +8 -1
  30. package/src/mcp/server.ts +63 -1
  31. package/src/runtime/discovery-rules.ts +20 -1
  32. package/src/runtime/event-hub.ts +6 -1
  33. package/src/runtime/network-guard.ts +180 -0
  34. package/src/runtime/queue-engine.ts +55 -21
  35. package/src/runtime/rate-limiter.ts +14 -0
  36. package/src/runtime/server.ts +572 -139
  37. package/src/version.ts +4 -0
  38. package/templates/starter/.jev.json +1 -1
@@ -26,25 +26,33 @@ import { findSliceFiles, resolveSlicesDir } from '../compiler/slice-discovery';
26
26
  import {
27
27
  type ActionContext,
28
28
  AnonymousSession,
29
+ BoundedLruCache,
29
30
  createActionContext,
30
31
  createDefaultLogger,
31
32
  createSession,
32
33
  type DatabaseClient,
33
34
  getDatabase,
34
35
  loadSynapseConfig,
36
+ normalizeCacheKey,
35
37
  optimizeImage,
36
38
  resetDatabaseInstance,
37
39
  type SessionContext,
38
- type SynapseConfig
40
+ type SliceCacheConfig,
41
+ type SliceSocketDefinition,
42
+ type SynapseConfig,
43
+ type SynapseWebSocket,
44
+ serializeCookie
39
45
  } from '../core/index';
40
46
  import { ROLES_COOKIE, SESSION_COOKIE } from '../core/session-cookie';
41
47
  import { verifySessionToken } from '../core/session-token';
48
+ import { SYNAPSE_VERSION } from '../version';
42
49
  import { serializeClientProps } from './client-entry';
43
- import { isAction, isComponent, isJob, isLoader, isMeta, isWebhook } from './discovery-rules';
50
+ import { isAction, isCache, isComponent, isJob, isLoader, isMeta, isSocket, isWebhook } from './discovery-rules';
44
51
  import { getEventHub } from './event-hub';
52
+ import { validateExternalUrl } from './network-guard';
45
53
  import { QueueEngine } from './queue-engine';
46
54
  import { TokenBucketRateLimiter } from './rate-limiter';
47
- import { saveUpload } from './uploads';
55
+ import { readBodyWithinLimit, saveUpload } from './uploads';
48
56
 
49
57
  export interface SliceMetadata {
50
58
  title?: string;
@@ -71,6 +79,7 @@ export interface DiscoveredSlice {
71
79
  routePath: string;
72
80
  rpcPath: string;
73
81
  webhookPath: string;
82
+ wsPath: string;
74
83
  filePath: string;
75
84
  actionFn?: (payload: unknown, dbOrCtx?: any, session?: SessionContext, extraCtx?: ActionContext) => Promise<any>;
76
85
  componentFn?: React.ComponentType<any>;
@@ -82,6 +91,10 @@ export interface DiscoveredSlice {
82
91
  metaFn?: (props: Record<string, unknown>, context: SliceLoaderContext) => SliceMetadata | Promise<SliceMetadata>;
83
92
  /** Optional Webhook handler receiving rawBody and ActionContext */
84
93
  webhookFn?: (event: WebhookEvent, ctx: ActionContext) => Promise<any>;
94
+ /** Optional SSR cache and ISR policy */
95
+ cacheConfig?: SliceCacheConfig;
96
+ /** Optional bidirectional WebSocket handler */
97
+ socketDef?: SliceSocketDefinition;
85
98
  }
86
99
 
87
100
  /**
@@ -137,9 +150,9 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
137
150
  curRoot.innerHTML = newRoot.innerHTML;
138
151
  document.title = doc.title;
139
152
  var newPropsScript = doc.querySelector('script[data-synapse-props]');
140
- if (newPropsScript) {
153
+ if (newPropsScript && newPropsScript.textContent) {
141
154
  try {
142
- eval(newPropsScript.textContent);
155
+ window.__SYNAPSE_PROPS__ = JSON.parse(newPropsScript.textContent);
143
156
  } catch (_) {}
144
157
  }
145
158
  if (push) history.pushState({}, '', href);
@@ -250,6 +263,29 @@ export class SynapseServer {
250
263
  private layoutComponent: React.ComponentType<any> | null = null;
251
264
  private domainLayouts: Map<string, React.ComponentType<any>> = new Map();
252
265
  private rateLimiter: TokenBucketRateLimiter;
266
+ private ssrCache: BoundedLruCache<{
267
+ html: string;
268
+ expiresAt: number;
269
+ staleUntil: number;
270
+ ttlSeconds: number;
271
+ swrSeconds: number;
272
+ tags: string[];
273
+ }>;
274
+ private revalidatingKeys = new Set<string>();
275
+
276
+ public invalidateCache(tags?: string[]): void {
277
+ if (!tags || tags.length === 0) {
278
+ this.ssrCache.clear();
279
+ return;
280
+ }
281
+ const tagSet = new Set(tags);
282
+ for (const [key, entry] of this.ssrCache.entries()) {
283
+ if (entry.tags.some((t) => tagSet.has(t))) {
284
+ this.ssrCache.delete(key);
285
+ }
286
+ }
287
+ }
288
+
253
289
  private metrics = {
254
290
  startTime: Date.now(),
255
291
  totalRequests: 0,
@@ -261,11 +297,15 @@ export class SynapseServer {
261
297
  };
262
298
  public port: number;
263
299
 
264
- constructor(baseDir: string = process.cwd(), port: number = 3000, db?: DatabaseClient) {
300
+ constructor(baseDir: string = process.cwd(), port: number = 3000, db?: DatabaseClient, config?: SynapseConfig) {
265
301
  this.baseDir = baseDir;
266
302
  this.port = port;
267
303
  this.db = db || getDatabase();
268
- this.rateLimiter = new TokenBucketRateLimiter();
304
+ if (config) {
305
+ this.config = { ...config };
306
+ }
307
+ this.rateLimiter = new TokenBucketRateLimiter({ maxBuckets: 10_000 });
308
+ this.ssrCache = new BoundedLruCache(this.config.cache?.maxEntries || 1000);
269
309
  this.queueEngine = new QueueEngine({
270
310
  dbPath: path.join(this.baseDir, '.synapse/queue.sqlite'),
271
311
  logger: createDefaultLogger('QueueEngine')
@@ -275,11 +315,14 @@ export class SynapseServer {
275
315
  /**
276
316
  * Gracefully shuts down the HTTP server and closes database connections.
277
317
  */
278
- async stop(): Promise<void> {
318
+ async stop(drainTimeoutMs = 5000): Promise<void> {
279
319
  if (this.httpServer) {
280
- this.httpServer.stop(true);
320
+ this.httpServer.stop(false);
281
321
  this.httpServer = null;
282
322
  }
323
+ if (drainTimeoutMs > 0) {
324
+ await new Promise((resolve) => setTimeout(resolve, Math.min(drainTimeoutMs, 200)));
325
+ }
283
326
  this.rateLimiter.close();
284
327
  this.queueEngine.close();
285
328
  await this.db.close?.();
@@ -321,7 +364,8 @@ export class SynapseServer {
321
364
  tenantId: session.tenantId,
322
365
  services: (this.config.services as Record<string, unknown>) || {},
323
366
  logger: createDefaultLogger(sliceName),
324
- enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options)
367
+ enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options),
368
+ invalidateCache: (tags) => this.invalidateCache(tags)
325
369
  });
326
370
  }
327
371
 
@@ -409,6 +453,8 @@ export class SynapseServer {
409
453
  let loaderFn: any = null;
410
454
  let webhookFn: any = null;
411
455
  let metaFn: any = null;
456
+ let cacheConfig: any = null;
457
+ let socketDef: any = null;
412
458
 
413
459
  for (const [exportName, val] of Object.entries(mod)) {
414
460
  if (
@@ -420,16 +466,29 @@ export class SynapseServer {
420
466
  ) {
421
467
  this.queueEngine.registerJob(val as any);
422
468
  }
469
+ if (isCache(exportName) && val && typeof val === 'object') {
470
+ cacheConfig = val;
471
+ }
472
+ if (isSocket(exportName) && val && typeof val === 'object') {
473
+ socketDef = val;
474
+ }
423
475
  if (typeof val !== 'function') {
424
476
  continue;
425
477
  }
426
- if (isAction(exportName)) {
478
+ if (exportName === 'default' && typeof val === 'function') {
479
+ componentFn = val;
480
+ componentExport = 'default';
481
+ } else if (isAction(exportName)) {
427
482
  actionFn = val;
428
483
  } else if (isLoader(exportName)) {
429
484
  loaderFn = val;
430
485
  } else if (isComponent(exportName)) {
431
- componentFn = val;
432
- componentExport = exportName;
486
+ if (componentExport !== 'default') {
487
+ if (!componentFn || exportName.endsWith('View')) {
488
+ componentFn = val;
489
+ componentExport = exportName;
490
+ }
491
+ }
433
492
  } else if (isWebhook(exportName)) {
434
493
  webhookFn = val;
435
494
  } else if (isMeta(exportName)) {
@@ -444,13 +503,16 @@ export class SynapseServer {
444
503
  routePath,
445
504
  rpcPath,
446
505
  webhookPath: `/_synapse/webhooks/${domain}/${name}`,
506
+ wsPath: `/_synapse/ws/${domain}/${name}`,
447
507
  filePath: file,
448
508
  actionFn,
449
509
  componentFn,
450
510
  componentExport,
451
511
  loaderFn,
452
512
  metaFn,
453
- webhookFn
513
+ webhookFn,
514
+ cacheConfig,
515
+ socketDef
454
516
  });
455
517
  } catch (err) {
456
518
  const message = err instanceof Error ? err.message : String(err);
@@ -462,6 +524,13 @@ export class SynapseServer {
462
524
  return this.slices.size;
463
525
  }
464
526
 
527
+ /**
528
+ * Registers a slice directly on this server instance (useful for programmatic slices and testing).
529
+ */
530
+ public registerSlice(slice: DiscoveredSlice): void {
531
+ this.slices.set(slice.key, slice);
532
+ }
533
+
465
534
  /**
466
535
  * `<domain>/<name>` resolves directly. A bare name resolves only while it is
467
536
  * unique across domains; ambiguity is an explicit conflict, never a guess.
@@ -584,13 +653,18 @@ export class SynapseServer {
584
653
  return verified.ok
585
654
  ? createSession({
586
655
  userId: verified.value.userId,
587
- tenantId: (verified.value as { tenantId?: string }).tenantId || tenantId,
656
+ tenantId: verified.value.tenantId || tenantId,
588
657
  roles: verified.value.roles,
589
658
  token
590
659
  })
591
660
  : AnonymousSession(tenantId);
592
661
  }
593
662
 
663
+ // Em produção sem SYNAPSE_SESSION_SECRET, forjamento de headers é terminantemente proibido (CWE-287)
664
+ if (process.env.NODE_ENV === 'production') {
665
+ return AnonymousSession(tenantId);
666
+ }
667
+
594
668
  // Modo de desenvolvimento (sem segredo configurado):
595
669
  // Aceita headers diretos ou cookies para conveniência local.
596
670
  const cookieRoles = cookies[ROLES_COOKIE];
@@ -656,7 +730,8 @@ export class SynapseServer {
656
730
  filePath: slice.filePath,
657
731
  rpcPath: slice.rpcPath
658
732
  },
659
- this.baseDir
733
+ this.baseDir,
734
+ { vendorPackages: this.config.compiler?.vendorPackages }
660
735
  );
661
736
 
662
737
  if (!built.ok) {
@@ -693,6 +768,119 @@ export class SynapseServer {
693
768
  return elements.join('\n');
694
769
  }
695
770
 
771
+ /**
772
+ * Renders the slice SSR component tree and packages the complete HTML shell
773
+ */
774
+ private async renderSliceHtml(
775
+ slice: DiscoveredSlice,
776
+ req: Request,
777
+ url: URL,
778
+ matchedLocale?: string
779
+ ): Promise<{
780
+ html: string;
781
+ dataProps: Record<string, unknown>;
782
+ meta?: SliceMetadata;
783
+ hasError?: boolean;
784
+ error?: string;
785
+ }> {
786
+ const session = this.sessionFrom(req);
787
+ const actionCtx = this.createActionContext(slice.name, session);
788
+ const params = Object.fromEntries(url.searchParams.entries());
789
+ const dataProps: Record<string, unknown> = {
790
+ ...params,
791
+ ...(matchedLocale ? { locale: matchedLocale } : {})
792
+ };
793
+ let loaderError: string | null = null;
794
+
795
+ const loaderCtx: SliceLoaderContext = {
796
+ url: url.toString(),
797
+ params,
798
+ db: this.db,
799
+ session,
800
+ ctx: actionCtx,
801
+ services: (this.config.services as Record<string, unknown>) || {},
802
+ locale: matchedLocale
803
+ };
804
+
805
+ if (slice.loaderFn) {
806
+ try {
807
+ const loaded = await slice.loaderFn(loaderCtx);
808
+ Object.assign(dataProps, loaded);
809
+ } catch (err) {
810
+ loaderError = err instanceof Error ? err.message : String(err);
811
+ console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
812
+ }
813
+ }
814
+
815
+ let meta: SliceMetadata | undefined;
816
+ if (slice.metaFn && !loaderError) {
817
+ try {
818
+ meta = await slice.metaFn(dataProps, loaderCtx);
819
+ } catch (err) {
820
+ console.error(`[SynapseServer] sliceMeta de ${slice.key} falhou:`, err);
821
+ }
822
+ }
823
+
824
+ const renderProps: Record<string, unknown> = {
825
+ ...dataProps,
826
+ ...(slice.actionFn
827
+ ? {
828
+ onSubmitAction: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx),
829
+ action: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx)
830
+ }
831
+ : {})
832
+ };
833
+
834
+ let contentHtml: string;
835
+ let hasError = false;
836
+ let renderError: string | null = loaderError;
837
+
838
+ const isProduction = process.env.NODE_ENV === 'production';
839
+ if (loaderError) {
840
+ hasError = true;
841
+ const displayMsg = isProduction ? 'Ocorreu um erro interno ao carregar os dados.' : escapeHtml(loaderError);
842
+ contentHtml = `<div class="text-rose-400">Falha no loader de ${escapeHtml(slice.key)}: ${displayMsg}</div>`;
843
+ } else if (slice.componentFn) {
844
+ try {
845
+ const sliceEl = React.createElement(slice.componentFn, renderProps);
846
+ const domainLayout = this.domainLayouts.get(slice.domain);
847
+ const treeWithDomainLayout = domainLayout
848
+ ? React.createElement(domainLayout, { session, url: url.toString() }, sliceEl)
849
+ : sliceEl;
850
+ const treeWithLayout = this.layoutComponent
851
+ ? React.createElement(this.layoutComponent, { session, url: url.toString() }, treeWithDomainLayout)
852
+ : treeWithDomainLayout;
853
+ const rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
854
+ contentHtml = renderToString(rootEl);
855
+ } catch (e: any) {
856
+ if (!isProduction) console.error('[SSR Render Error]', e);
857
+ hasError = true;
858
+ const errMessage = e instanceof Error ? e.message : String(e);
859
+ renderError = errMessage;
860
+ const displayMsg = isProduction ? 'Ocorreu um erro interno durante a renderização.' : escapeHtml(errMessage);
861
+ contentHtml = `<div class="text-rose-400">Erro na renderização SSR: ${displayMsg}</div>`;
862
+ }
863
+ } else {
864
+ contentHtml = '<div class="text-slate-400">Nenhum componente de UI exportado.</div>';
865
+ }
866
+
867
+ const clientUrl = hasError ? null : await this.ensureClientBundle(slice);
868
+
869
+ const hasCustomLayout = Boolean(this.layoutComponent || this.domainLayouts.get(slice.domain));
870
+ const html = this.renderHtmlShell(
871
+ meta?.title || slice.name,
872
+ contentHtml,
873
+ slice.name,
874
+ serializeClientProps(dataProps),
875
+ clientUrl,
876
+ meta,
877
+ matchedLocale || 'pt-BR',
878
+ hasCustomLayout
879
+ );
880
+
881
+ return { html, dataProps, meta, hasError, error: renderError || undefined };
882
+ }
883
+
696
884
  /**
697
885
  * Generates the SSR HTML Shell with Inter font, Tailwind and client hydration
698
886
  */
@@ -737,6 +925,22 @@ export class SynapseServer {
737
925
  }
738
926
  const metaSection = metaTags.length > 0 ? `\n${metaTags.join('\n')}` : '';
739
927
 
928
+ const vendorImports: Record<string, string> = {
929
+ react: '/_synapse/client/_vendor.js',
930
+ 'react/jsx-runtime': '/_synapse/client/_vendor.js',
931
+ 'react/jsx-dev-runtime': '/_synapse/client/_vendor.js',
932
+ 'react-dom': '/_synapse/client/_vendor.js',
933
+ 'react-dom/client': '/_synapse/client/_vendor.js',
934
+ 'synapsejs/client': '/_synapse/client/_vendor.js',
935
+ '@ismaelsoilet/synapsejs/client': '/_synapse/client/_vendor.js'
936
+ };
937
+ if (this.config.compiler?.vendorPackages) {
938
+ for (const pkg of this.config.compiler.vendorPackages) {
939
+ vendorImports[pkg] = '/_synapse/client/_vendor.js';
940
+ }
941
+ }
942
+ const importMapScript = ` <script type="importmap">\n ${JSON.stringify({ imports: vendorImports }, null, 4).split('\n').join('\n ')}\n </script>`;
943
+
740
944
  if (hasLayout) {
741
945
  return `<!DOCTYPE html>
742
946
  <html lang="${escapeHtml(locale)}" class="min-h-full">
@@ -745,19 +949,7 @@ export class SynapseServer {
745
949
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
746
950
  <title>${pageTitle}</title>${metaSection}
747
951
  ${this.renderStylesheets()}
748
- <script type="importmap">
749
- {
750
- "imports": {
751
- "react": "/_synapse/client/_vendor.js",
752
- "react/jsx-runtime": "/_synapse/client/_vendor.js",
753
- "react/jsx-dev-runtime": "/_synapse/client/_vendor.js",
754
- "react-dom": "/_synapse/client/_vendor.js",
755
- "react-dom/client": "/_synapse/client/_vendor.js",
756
- "synapsejs/client": "/_synapse/client/_vendor.js",
757
- "@ismaelsoilet/synapsejs/client": "/_synapse/client/_vendor.js"
758
- }
759
- }
760
- </script>
952
+ ${importMapScript}
761
953
  <script type="module" src="/_synapse/client/_vendor.js"></script>
762
954
  <style>
763
955
  body { font-family: 'Inter', sans-serif; }
@@ -767,7 +959,8 @@ ${this.renderStylesheets()}
767
959
  <body class="min-h-full flex flex-col">
768
960
  <div id="synapse-root" class="min-h-full flex-1 flex flex-col">${contentHtml}</div>
769
961
 
770
- <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
962
+ <script data-synapse-props id="__SYNAPSE_PROPS_DATA__" type="application/json">${propsJson}</script>
963
+ <script>globalThis.__SYNAPSE_PROPS__ = JSON.parse(document.getElementById('__SYNAPSE_PROPS_DATA__').textContent || '{}');</script>
771
964
  ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
772
965
 
773
966
  <script type="module" src="/_synapse/turbo-router.js"></script>
@@ -782,19 +975,7 @@ ${this.renderStylesheets()}
782
975
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
783
976
  <title>${pageTitle}</title>${metaSection}
784
977
  ${this.renderStylesheets()}
785
- <script type="importmap">
786
- {
787
- "imports": {
788
- "react": "/_synapse/client/_vendor.js",
789
- "react/jsx-runtime": "/_synapse/client/_vendor.js",
790
- "react/jsx-dev-runtime": "/_synapse/client/_vendor.js",
791
- "react-dom": "/_synapse/client/_vendor.js",
792
- "react-dom/client": "/_synapse/client/_vendor.js",
793
- "synapsejs/client": "/_synapse/client/_vendor.js",
794
- "@ismaelsoilet/synapsejs/client": "/_synapse/client/_vendor.js"
795
- }
796
- }
797
- </script>
978
+ ${importMapScript}
798
979
  <script type="module" src="/_synapse/client/_vendor.js"></script>
799
980
  <style>
800
981
  body { font-family: 'Inter', sans-serif; }
@@ -835,7 +1016,8 @@ ${this.renderStylesheets()}
835
1016
  <div id="synapse-root" class="bg-slate-900/80 border border-slate-800 rounded-xl p-6 shadow-2xl backdrop-blur">${contentHtml}</div>
836
1017
 
837
1018
  <!-- Props de hidratacao e a entrada de cliente gerada -->
838
- <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
1019
+ <script data-synapse-props id="__SYNAPSE_PROPS_DATA__" type="application/json">${propsJson}</script>
1020
+ <script>globalThis.__SYNAPSE_PROPS__ = JSON.parse(document.getElementById('__SYNAPSE_PROPS_DATA__').textContent || '{}');</script>
839
1021
  ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
840
1022
 
841
1023
  <!-- Turbo Morphing SPA Navigation (<1.5kb, Zero Dependencies) -->
@@ -947,6 +1129,10 @@ ${this.renderStylesheets()}
947
1129
  this.config = {
948
1130
  ...loadedConfig,
949
1131
  ...this.config,
1132
+ compiler: {
1133
+ ...(loadedConfig.compiler || {}),
1134
+ ...(this.config?.compiler || {})
1135
+ },
950
1136
  plugins: [...(loadedConfig.plugins || []), ...(this.config?.plugins || [])]
951
1137
  };
952
1138
 
@@ -966,6 +1152,46 @@ ${this.renderStylesheets()}
966
1152
 
967
1153
  this.httpServer = Bun.serve({
968
1154
  port: this.port,
1155
+ websocket: {
1156
+ open: async (ws: any) => {
1157
+ const socketDef = ws.data?.socketDef;
1158
+ if (socketDef?.onOpen) {
1159
+ try {
1160
+ await socketDef.onOpen(ws);
1161
+ } catch (err) {
1162
+ console.error(`[SynapseServer] Erro no onOpen do WebSocket (${ws.data?.sliceKey}):`, err);
1163
+ }
1164
+ }
1165
+ },
1166
+ message: async (ws: any, message: any) => {
1167
+ const socketDef = ws.data?.socketDef;
1168
+ if (socketDef?.onMessage) {
1169
+ try {
1170
+ let parsed = message;
1171
+ if (typeof message === 'string') {
1172
+ try {
1173
+ parsed = JSON.parse(message);
1174
+ } catch {
1175
+ // mantém texto puro
1176
+ }
1177
+ }
1178
+ await socketDef.onMessage(ws, parsed);
1179
+ } catch (err) {
1180
+ console.error(`[SynapseServer] Erro no onMessage do WebSocket (${ws.data?.sliceKey}):`, err);
1181
+ }
1182
+ }
1183
+ },
1184
+ close: async (ws: any, code: number, reason: string) => {
1185
+ const socketDef = ws.data?.socketDef;
1186
+ if (socketDef?.onClose) {
1187
+ try {
1188
+ await socketDef.onClose(ws, code, reason);
1189
+ } catch (err) {
1190
+ console.error(`[SynapseServer] Erro no onClose do WebSocket (${ws.data?.sliceKey}):`, err);
1191
+ }
1192
+ }
1193
+ }
1194
+ },
969
1195
  fetch: async (req: Request) => {
970
1196
  if (this.config.plugins) {
971
1197
  for (const plugin of this.config.plugins) {
@@ -983,6 +1209,79 @@ ${this.renderStylesheets()}
983
1209
  const pathname = url.pathname;
984
1210
  const startedAt = performance.now();
985
1211
 
1212
+ // WebSocket Upgrade for slices: /_synapse/ws/:domain/:name
1213
+ if (pathname.startsWith('/_synapse/ws/')) {
1214
+ const origin = req.headers.get('origin');
1215
+ const allowed = (process.env.SYNAPSE_ALLOWED_ORIGINS ?? '')
1216
+ .split(',')
1217
+ .map((entry) => entry.trim())
1218
+ .filter((entry) => entry.length > 0);
1219
+ if (origin && allowed.length > 0 && !allowed.includes(origin) && !allowed.includes('*')) {
1220
+ return new Response(JSON.stringify({ error: 'Origin não permitida para conexão WebSocket' }), {
1221
+ status: 403,
1222
+ headers: { 'Content-Type': 'application/json' }
1223
+ });
1224
+ }
1225
+
1226
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY !== 'false' && this.config.trustProxy !== false;
1227
+ let clientIp: string;
1228
+ if (trustProxy) {
1229
+ clientIp =
1230
+ req.headers.get('cf-connecting-ip') ||
1231
+ req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
1232
+ this.httpServer?.requestIP(req)?.address ||
1233
+ '127.0.0.1';
1234
+ } else {
1235
+ clientIp = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
1236
+ }
1237
+ const rateResult = this.rateLimiter.consume(clientIp);
1238
+ if (!rateResult.allowed) {
1239
+ return new Response(
1240
+ JSON.stringify({
1241
+ ok: false,
1242
+ error: 'RATE_LIMIT_EXCEEDED',
1243
+ message: 'Muitas conexões WebSocket. Tente novamente mais tarde.'
1244
+ }),
1245
+ {
1246
+ status: 429,
1247
+ headers: {
1248
+ 'Content-Type': 'application/json; charset=utf-8',
1249
+ 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000))
1250
+ }
1251
+ }
1252
+ );
1253
+ }
1254
+
1255
+ const target = pathname.slice('/_synapse/ws/'.length);
1256
+ const resolved = this.resolveSlice(target);
1257
+ if (!resolved.ok) {
1258
+ return new Response(JSON.stringify({ error: resolved.message }), {
1259
+ status: resolved.status,
1260
+ headers: { 'Content-Type': 'application/json' }
1261
+ });
1262
+ }
1263
+ const socketDef = resolved.slice.socketDef;
1264
+ if (!socketDef) {
1265
+ return new Response(JSON.stringify({ error: `Fatia ${resolved.slice.key} não declara sliceSocket` }), {
1266
+ status: 404,
1267
+ headers: { 'Content-Type': 'application/json' }
1268
+ });
1269
+ }
1270
+ const session = this.sessionFrom(req);
1271
+ const upgraded = (this.httpServer as any)?.upgrade(req, {
1272
+ data: {
1273
+ id: crypto.randomUUID(),
1274
+ sliceKey: resolved.slice.key,
1275
+ socketDef,
1276
+ session
1277
+ }
1278
+ });
1279
+ if (upgraded) {
1280
+ return undefined as any;
1281
+ }
1282
+ return new Response('Falha no upgrade de WebSocket', { status: 400 });
1283
+ }
1284
+
986
1285
  // Arquivos estáticos primeiro: `public/` do app, estritamente contido no diretório.
987
1286
  if (!pathname.startsWith('/_synapse/') && pathname !== '/') {
988
1287
  const publicDir = path.resolve(this.baseDir, 'public');
@@ -1042,7 +1341,7 @@ ${this.renderStylesheets()}
1042
1341
  {
1043
1342
  status,
1044
1343
  framework: 'SynapseJS',
1045
- version: '1.2.0',
1344
+ version: SYNAPSE_VERSION,
1046
1345
  uptime: process.uptime(),
1047
1346
  database: dbHealthy ? 'connected' : 'disconnected',
1048
1347
  ...(dbError ? { databaseError: dbError } : {}),
@@ -1103,17 +1402,25 @@ ${this.renderStylesheets()}
1103
1402
  });
1104
1403
  }
1105
1404
 
1106
- // 3. Rate Limiter for Sensitive Endpoints (RPC, Uploads, Webhooks)
1405
+ // 3. Rate Limiter for Sensitive Endpoints (RPC, Uploads, Webhooks, SSE)
1107
1406
  if (
1108
1407
  pathname.startsWith('/_synapse/rpc/') ||
1109
1408
  pathname.startsWith('/_synapse/files/') ||
1110
- pathname.startsWith('/_synapse/webhooks/')
1409
+ pathname.startsWith('/_synapse/webhooks/') ||
1410
+ pathname.startsWith('/_synapse/sse/')
1111
1411
  ) {
1112
- const clientKey =
1113
- req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
1114
- req.headers.get('cf-connecting-ip') ||
1115
- req.headers.get('authorization') ||
1116
- 'global';
1412
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY !== 'false' && this.config.trustProxy !== false;
1413
+ let clientIp: string;
1414
+ if (trustProxy) {
1415
+ clientIp =
1416
+ req.headers.get('cf-connecting-ip') ||
1417
+ req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
1418
+ this.httpServer?.requestIP(req)?.address ||
1419
+ '127.0.0.1';
1420
+ } else {
1421
+ clientIp = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
1422
+ }
1423
+ const clientKey = clientIp;
1117
1424
 
1118
1425
  const rateResult = this.rateLimiter.consume(clientKey);
1119
1426
  if (!rateResult.allowed) {
@@ -1182,8 +1489,51 @@ ${this.renderStylesheets()}
1182
1489
  );
1183
1490
  }
1184
1491
 
1492
+ const maxRpcBytes = this.config.maxRpcPayloadBytes ?? 5 * 1024 * 1024; // 5 MB
1493
+ const rpcContentLengthHeader = req.headers.get('content-length');
1494
+ const rpcContentLength = rpcContentLengthHeader ? parseInt(rpcContentLengthHeader, 10) : NaN;
1495
+ if (Number.isFinite(rpcContentLength) && rpcContentLength > maxRpcBytes) {
1496
+ this.logRequest(req, pathname, 413, startedAt, undefined, 'rpc_error');
1497
+ return Response.json(
1498
+ {
1499
+ ok: false,
1500
+ error: 'PAYLOAD_TOO_LARGE',
1501
+ message: `Payload RPC excede o limite máximo permitido de ${maxRpcBytes} bytes.`
1502
+ },
1503
+ { status: 413, headers: cors ?? undefined }
1504
+ );
1505
+ }
1506
+
1507
+ const bodyResult = await readBodyWithinLimit(req.body, maxRpcBytes);
1508
+ if (!bodyResult.ok) {
1509
+ this.logRequest(req, pathname, 413, startedAt, undefined, 'rpc_error');
1510
+ return Response.json(
1511
+ {
1512
+ ok: false,
1513
+ error: 'PAYLOAD_TOO_LARGE',
1514
+ message: `Payload RPC excede o limite máximo permitido de ${maxRpcBytes} bytes.`
1515
+ },
1516
+ { status: 413, headers: cors ?? undefined }
1517
+ );
1518
+ }
1519
+
1520
+ let body: unknown;
1521
+ try {
1522
+ const bodyText = new TextDecoder().decode(bodyResult.value);
1523
+ body = bodyText.trim().length > 0 ? JSON.parse(bodyText) : {};
1524
+ } catch {
1525
+ this.logRequest(req, pathname, 400, startedAt, undefined, 'rpc_error');
1526
+ return Response.json(
1527
+ {
1528
+ ok: false,
1529
+ error: 'INVALID_JSON_PAYLOAD',
1530
+ message: 'Payload da requisição não é um JSON válido.'
1531
+ },
1532
+ { status: 400, headers: cors ?? undefined }
1533
+ );
1534
+ }
1535
+
1185
1536
  try {
1186
- const body = await req.json();
1187
1537
  const session = this.sessionFrom(req);
1188
1538
  const actionCtx = this.createActionContext(slice.name, session);
1189
1539
 
@@ -1192,7 +1542,16 @@ ${this.renderStylesheets()}
1192
1542
  const metricType = result.ok ? 'rpc_success' : 'rpc_error';
1193
1543
  this.logRequest(req, pathname, status, startedAt, session, metricType);
1194
1544
 
1195
- return Response.json(result, { status, headers: cors ?? undefined });
1545
+ const headers = new Headers(cors ?? {});
1546
+ headers.set('Content-Type', 'application/json');
1547
+
1548
+ if (actionCtx._pendingCookies && actionCtx._pendingCookies.length > 0) {
1549
+ for (const cookie of actionCtx._pendingCookies) {
1550
+ headers.append('Set-Cookie', serializeCookie(cookie.name, cookie.value, cookie.options));
1551
+ }
1552
+ }
1553
+
1554
+ return new Response(JSON.stringify(result), { status, headers });
1196
1555
  } catch (err: any) {
1197
1556
  this.logRequest(req, pathname, 500, startedAt, undefined, 'rpc_error');
1198
1557
  return Response.json({ ok: false, error: `Falha interna no RPC: ${err.message}` }, { status: 500 });
@@ -1231,8 +1590,36 @@ ${this.renderStylesheets()}
1231
1590
  );
1232
1591
  }
1233
1592
 
1593
+ const maxWebhookBytes = this.config.maxWebhookPayloadBytes ?? 10 * 1024 * 1024; // 10 MB
1594
+ const webhookContentLengthHeader = req.headers.get('content-length');
1595
+ const webhookContentLength = webhookContentLengthHeader ? parseInt(webhookContentLengthHeader, 10) : NaN;
1596
+ if (Number.isFinite(webhookContentLength) && webhookContentLength > maxWebhookBytes) {
1597
+ this.logRequest(req, pathname, 413, startedAt);
1598
+ return Response.json(
1599
+ {
1600
+ ok: false,
1601
+ error: 'PAYLOAD_TOO_LARGE',
1602
+ message: `Payload Webhook excede o limite máximo permitido de ${maxWebhookBytes} bytes.`
1603
+ },
1604
+ { status: 413, headers: cors ?? undefined }
1605
+ );
1606
+ }
1607
+
1608
+ const bodyResult = await readBodyWithinLimit(req.body, maxWebhookBytes);
1609
+ if (!bodyResult.ok) {
1610
+ this.logRequest(req, pathname, 413, startedAt);
1611
+ return Response.json(
1612
+ {
1613
+ ok: false,
1614
+ error: 'PAYLOAD_TOO_LARGE',
1615
+ message: `Payload Webhook excede o limite máximo permitido de ${maxWebhookBytes} bytes.`
1616
+ },
1617
+ { status: 413, headers: cors ?? undefined }
1618
+ );
1619
+ }
1620
+
1234
1621
  try {
1235
- const rawBody = new Uint8Array(await req.arrayBuffer());
1622
+ const rawBody = bodyResult.value;
1236
1623
  const bodyText = new TextDecoder().decode(rawBody);
1237
1624
  let json: unknown = null;
1238
1625
  try {
@@ -1295,6 +1682,19 @@ ${this.renderStylesheets()}
1295
1682
  let unsubscribe: (() => void) | null = null;
1296
1683
  let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
1297
1684
 
1685
+ const cleanup = () => {
1686
+ if (keepAliveTimer) {
1687
+ clearInterval(keepAliveTimer);
1688
+ keepAliveTimer = null;
1689
+ }
1690
+ if (unsubscribe) {
1691
+ unsubscribe();
1692
+ unsubscribe = null;
1693
+ }
1694
+ };
1695
+
1696
+ req.signal.addEventListener('abort', cleanup);
1697
+
1298
1698
  const stream = new ReadableStream({
1299
1699
  start(controller) {
1300
1700
  const encoder = new TextEncoder();
@@ -1304,7 +1704,7 @@ ${this.renderStylesheets()}
1304
1704
  try {
1305
1705
  controller.enqueue(encoder.encode(': keep-alive\n\n'));
1306
1706
  } catch {
1307
- if (keepAliveTimer) clearInterval(keepAliveTimer);
1707
+ cleanup();
1308
1708
  }
1309
1709
  }, 15000);
1310
1710
 
@@ -1318,8 +1718,7 @@ ${this.renderStylesheets()}
1318
1718
  });
1319
1719
  },
1320
1720
  cancel() {
1321
- if (keepAliveTimer) clearInterval(keepAliveTimer);
1322
- if (unsubscribe) unsubscribe();
1721
+ cleanup();
1323
1722
  }
1324
1723
  });
1325
1724
 
@@ -1387,17 +1786,45 @@ ${this.renderStylesheets()}
1387
1786
  let mimeType = 'image/jpeg';
1388
1787
 
1389
1788
  if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
1789
+ const allowedDomains =
1790
+ this.config.imageOptimizer?.allowedDomains ||
1791
+ (process.env.SYNAPSE_ALLOWED_IMAGE_DOMAINS
1792
+ ? process.env.SYNAPSE_ALLOWED_IMAGE_DOMAINS.split(',').map((s) => s.trim())
1793
+ : undefined);
1794
+
1795
+ const validation = await validateExternalUrl(fileUrl, { allowedDomains });
1796
+ if (!validation.ok) {
1797
+ return Response.json(
1798
+ { ok: false, error: validation.error || 'FORBIDDEN_TARGET_URL' },
1799
+ { status: 403, headers: cors ?? undefined }
1800
+ );
1801
+ }
1802
+
1390
1803
  const res = await fetch(fileUrl);
1391
1804
  if (!res.ok) {
1392
- return Response.json({ ok: false, error: 'FETCH_IMAGE_FAILED' }, { status: 502 });
1805
+ return Response.json(
1806
+ { ok: false, error: 'FETCH_IMAGE_FAILED' },
1807
+ { status: 502, headers: cors ?? undefined }
1808
+ );
1393
1809
  }
1394
1810
  buffer = new Uint8Array(await res.arrayBuffer());
1395
1811
  mimeType = res.headers.get('content-type') || mimeType;
1396
1812
  } else {
1813
+ const publicDir = path.resolve(this.baseDir, 'public');
1397
1814
  const cleanRel = fileUrl.replace(/^\/+/, '');
1398
- const candidate = path.resolve(this.baseDir, cleanRel);
1815
+ const candidate = path.resolve(publicDir, cleanRel);
1816
+
1817
+ if (!candidate.startsWith(publicDir + path.sep) && candidate !== publicDir) {
1818
+ return Response.json(
1819
+ { ok: false, error: 'FORBIDDEN_FILE_PATH' },
1820
+ { status: 403, headers: cors ?? undefined }
1821
+ );
1822
+ }
1399
1823
  if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
1400
- return Response.json({ ok: false, error: 'IMAGE_NOT_FOUND' }, { status: 404 });
1824
+ return Response.json(
1825
+ { ok: false, error: 'IMAGE_NOT_FOUND' },
1826
+ { status: 404, headers: cors ?? undefined }
1827
+ );
1401
1828
  }
1402
1829
  buffer = new Uint8Array(await Bun.file(candidate).arrayBuffer());
1403
1830
  }
@@ -1436,7 +1863,7 @@ ${this.renderStylesheets()}
1436
1863
  const bundleFilename = path.basename(pathname);
1437
1864
  const bundleFile = path.join(this.baseDir, '.synapse/client', bundleFilename);
1438
1865
  if (!fs.existsSync(bundleFile) && bundleFilename === VENDOR_BUNDLE_NAME) {
1439
- await buildVendorBundle(this.baseDir);
1866
+ await buildVendorBundle(this.baseDir, this.config.compiler?.vendorPackages);
1440
1867
  }
1441
1868
  if (!fs.existsSync(bundleFile)) {
1442
1869
  this.logRequest(req, pathname, 404, startedAt);
@@ -1476,94 +1903,100 @@ ${this.renderStylesheets()}
1476
1903
  if (resolvedSlice) {
1477
1904
  const slice = resolvedSlice;
1478
1905
  const session = this.sessionFrom(req);
1479
- const actionCtx = this.createActionContext(slice.name, session);
1480
- const params = Object.fromEntries(url.searchParams.entries());
1481
- const dataProps: Record<string, unknown> = {
1482
- ...params,
1483
- ...(matchedLocale ? { locale: matchedLocale } : {})
1484
- };
1485
- let loaderError: string | null = null;
1486
-
1487
- const loaderCtx: SliceLoaderContext = {
1488
- url: url.toString(),
1489
- params,
1490
- db: this.db,
1491
- session,
1492
- ctx: actionCtx,
1493
- services: (this.config.services as Record<string, unknown>) || {},
1494
- locale: matchedLocale
1495
- };
1906
+ const cacheConfig = slice.cacheConfig;
1907
+ const scopePrefix = `${session.tenantId || 'global'}:${session.userId !== 'anon' ? session.userId : 'public'}`;
1908
+ const normalizedKey = cacheConfig
1909
+ ? normalizeCacheKey(url, cacheConfig.allowedParams)
1910
+ : `${pathname}${url.search}`;
1911
+ const cacheKey = `${scopePrefix}:${normalizedKey}`;
1912
+ const now = Date.now();
1913
+
1914
+ if (cacheConfig && req.method === 'GET') {
1915
+ const cached = this.ssrCache.get(cacheKey);
1916
+ if (cached) {
1917
+ if (now < cached.expiresAt) {
1918
+ this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
1919
+ return new Response(cached.html, {
1920
+ headers: {
1921
+ 'Content-Type': 'text/html; charset=utf-8',
1922
+ 'X-Synapse-Cache': 'HIT',
1923
+ 'Cache-Control': `public, max-age=${cached.ttlSeconds}, stale-while-revalidate=${cached.swrSeconds}`
1924
+ }
1925
+ });
1926
+ }
1927
+ if (now < cached.staleUntil) {
1928
+ this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
1929
+ // Revalidação em background (ISR / Stale-While-Revalidate) - Thundering Herd Guard
1930
+ if (!this.revalidatingKeys.has(cacheKey)) {
1931
+ this.revalidatingKeys.add(cacheKey);
1932
+ (async () => {
1933
+ try {
1934
+ const fresh = await this.renderSliceHtml(slice, req, url, matchedLocale);
1935
+ if (!fresh.hasError) {
1936
+ const ttl = cacheConfig.ttlSeconds;
1937
+ const swr = cacheConfig.staleWhileRevalidateSeconds ?? 0;
1938
+ this.ssrCache.set(cacheKey, {
1939
+ html: fresh.html,
1940
+ expiresAt: Date.now() + ttl * 1000,
1941
+ staleUntil: Date.now() + (ttl + swr) * 1000,
1942
+ ttlSeconds: ttl,
1943
+ swrSeconds: swr,
1944
+ tags: cacheConfig.tags || []
1945
+ });
1946
+ }
1947
+ } catch (err) {
1948
+ console.error(`[SynapseServer] Revalidação ISR em background de ${slice.key} falhou:`, err);
1949
+ } finally {
1950
+ this.revalidatingKeys.delete(cacheKey);
1951
+ }
1952
+ })();
1953
+ }
1496
1954
 
1497
- if (slice.loaderFn) {
1498
- try {
1499
- const loaded = await slice.loaderFn(loaderCtx);
1500
- Object.assign(dataProps, loaded);
1501
- } catch (err) {
1502
- loaderError = err instanceof Error ? err.message : String(err);
1503
- console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
1955
+ return new Response(cached.html, {
1956
+ headers: {
1957
+ 'Content-Type': 'text/html; charset=utf-8',
1958
+ 'X-Synapse-Cache': 'STALE',
1959
+ 'Cache-Control': `public, max-age=${cached.ttlSeconds}, stale-while-revalidate=${cached.swrSeconds}`
1960
+ }
1961
+ });
1962
+ }
1504
1963
  }
1505
1964
  }
1506
1965
 
1507
- let meta: SliceMetadata | undefined;
1508
- if (slice.metaFn && !loaderError) {
1509
- try {
1510
- meta = await slice.metaFn(dataProps, loaderCtx);
1511
- } catch (err) {
1512
- console.error(`[SynapseServer] sliceMeta de ${slice.key} falhou:`, err);
1513
- }
1514
- }
1966
+ const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale);
1515
1967
 
1516
- const clientUrl = loaderError ? null : await this.ensureClientBundle(slice);
1968
+ if (hasError) {
1969
+ this.logRequest(req, pathname, 500, startedAt, session, 'ssr');
1970
+ return new Response(html, {
1971
+ status: 500,
1972
+ headers: {
1973
+ 'Content-Type': 'text/html; charset=utf-8',
1974
+ 'X-Robots-Tag': 'noindex, nofollow'
1975
+ }
1976
+ });
1977
+ }
1517
1978
 
1518
- const renderProps: Record<string, unknown> = {
1519
- ...dataProps,
1520
- ...(slice.actionFn
1521
- ? {
1522
- onSubmitAction: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx),
1523
- action: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx)
1524
- }
1525
- : {})
1979
+ const headers: Record<string, string> = {
1980
+ 'Content-Type': 'text/html; charset=utf-8'
1526
1981
  };
1527
1982
 
1528
- let contentHtml: string;
1529
- if (loaderError) {
1530
- contentHtml = `<div class="text-rose-400">Falha no loader de ${escapeHtml(slice.key)}: ${escapeHtml(loaderError)}</div>`;
1531
- } else if (slice.componentFn) {
1532
- try {
1533
- const sliceEl = React.createElement(slice.componentFn, renderProps);
1534
- const domainLayout = this.domainLayouts.get(slice.domain);
1535
- const treeWithDomainLayout = domainLayout
1536
- ? React.createElement(domainLayout, { session, url: url.toString() }, sliceEl)
1537
- : sliceEl;
1538
- const treeWithLayout = this.layoutComponent
1539
- ? React.createElement(this.layoutComponent, { session, url: url.toString() }, treeWithDomainLayout)
1540
- : treeWithDomainLayout;
1541
- const rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
1542
- contentHtml = renderToString(rootEl);
1543
- } catch (e: any) {
1544
- const errMessage = e instanceof Error ? e.message : String(e);
1545
- contentHtml = `<div class="text-rose-400">Erro na renderização SSR: ${escapeHtml(errMessage)}</div>`;
1546
- }
1547
- } else {
1548
- contentHtml = '<div class="text-slate-400">Nenhum componente de UI exportado.</div>';
1983
+ if (cacheConfig && req.method === 'GET') {
1984
+ const ttl = cacheConfig.ttlSeconds;
1985
+ const swr = cacheConfig.staleWhileRevalidateSeconds ?? 0;
1986
+ this.ssrCache.set(cacheKey, {
1987
+ html,
1988
+ expiresAt: now + ttl * 1000,
1989
+ staleUntil: now + (ttl + swr) * 1000,
1990
+ ttlSeconds: ttl,
1991
+ swrSeconds: swr,
1992
+ tags: cacheConfig.tags || []
1993
+ });
1994
+ headers['X-Synapse-Cache'] = 'MISS';
1995
+ headers['Cache-Control'] = `public, max-age=${ttl}, stale-while-revalidate=${swr}`;
1549
1996
  }
1550
1997
 
1551
1998
  this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
1552
-
1553
- const hasCustomLayout = Boolean(this.layoutComponent || this.domainLayouts.get(slice.domain));
1554
- const html = this.renderHtmlShell(
1555
- meta?.title || slice.name,
1556
- contentHtml,
1557
- slice.name,
1558
- serializeClientProps(dataProps),
1559
- clientUrl,
1560
- meta,
1561
- matchedLocale || 'pt-BR',
1562
- hasCustomLayout
1563
- );
1564
- return new Response(html, {
1565
- headers: { 'Content-Type': 'text/html; charset=utf-8' }
1566
- });
1999
+ return new Response(html, { headers });
1567
2000
  }
1568
2001
 
1569
2002
  this.logRequest(req, pathname, 404, startedAt);