@ismaelsoilet/synapsejs 1.4.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +108 -10
  2. package/bin/synapse.ts +263 -218
  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/context-bench.ts +7 -1
  11. package/src/compiler/contract.ts +31 -4
  12. package/src/compiler/db-schema-generator.ts +39 -8
  13. package/src/compiler/fields-parser.ts +28 -1
  14. package/src/compiler/impact-analyzer.ts +31 -1
  15. package/src/compiler/migration-runner.ts +144 -11
  16. package/src/compiler/optional-coverage.ts +137 -0
  17. package/src/compiler/oracle-runner.ts +8 -3
  18. package/src/compiler/scaffolder.ts +174 -3
  19. package/src/compiler/schema-dag.ts +6 -3
  20. package/src/compiler/slice-discovery.ts +8 -1
  21. package/src/compiler/slice-splitter.ts +67 -6
  22. package/src/compiler/slice-templates.ts +10 -0
  23. package/src/compiler/standalone-builder.ts +16 -2
  24. package/src/core/action-context.ts +101 -8
  25. package/src/core/cache.ts +139 -0
  26. package/src/core/config.ts +124 -10
  27. package/src/core/database-client.ts +68 -6
  28. package/src/core/index.ts +5 -0
  29. package/src/core/path-guard.ts +105 -0
  30. package/src/core/postgres-client.ts +6 -0
  31. package/src/core/postgres-queue.ts +45 -7
  32. package/src/core/query-builder.ts +75 -60
  33. package/src/core/session-context.ts +1 -1
  34. package/src/core/session-token.ts +115 -8
  35. package/src/core/sqlite-client.ts +26 -1
  36. package/src/core/storage.ts +61 -24
  37. package/src/core/topics.ts +84 -0
  38. package/src/core/webhook-signature.ts +151 -0
  39. package/src/core/websocket.ts +30 -0
  40. package/src/index.ts +10 -2
  41. package/src/mcp/server.ts +78 -173
  42. package/src/mcp/tools.ts +238 -0
  43. package/src/project-status.ts +330 -0
  44. package/src/runtime/discovery-rules.ts +20 -1
  45. package/src/runtime/event-hub.ts +6 -1
  46. package/src/runtime/network-guard.ts +182 -0
  47. package/src/runtime/queue-engine.ts +102 -21
  48. package/src/runtime/rate-limiter.ts +24 -0
  49. package/src/runtime/server.ts +1380 -251
  50. package/src/runtime/uploads.ts +199 -19
  51. package/src/version.ts +11 -0
  52. package/templates/starter/.jev.json +1 -1
  53. package/templates/starter/tsconfig.json +1 -0
@@ -26,25 +26,34 @@ 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,
35
+ hasAnyRole,
34
36
  loadSynapseConfig,
37
+ normalizeCacheKey,
35
38
  optimizeImage,
36
39
  resetDatabaseInstance,
37
40
  type SessionContext,
38
- type SynapseConfig
41
+ type SliceCacheConfig,
42
+ type SliceSocketDefinition,
43
+ type SynapseConfig,
44
+ serializeCookie
39
45
  } from '../core/index';
40
46
  import { ROLES_COOKIE, SESSION_COOKIE } from '../core/session-cookie';
41
- import { verifySessionToken } from '../core/session-token';
47
+ import { isSessionTokenRevoked, isSessionTokenRevokedInDb, verifySessionToken } from '../core/session-token';
48
+ import { declaredTopic, type RealtimeRejectionCode, scopedTopic } from '../core/topics';
49
+ import { SYNAPSE_VERSION } from '../version';
42
50
  import { serializeClientProps } from './client-entry';
43
- import { isAction, isComponent, isJob, isLoader, isMeta, isWebhook } from './discovery-rules';
51
+ import { isAction, isCache, isComponent, isJob, isLoader, isMeta, isSocket, isWebhook } from './discovery-rules';
44
52
  import { getEventHub } from './event-hub';
53
+ import { validateExternalUrl } from './network-guard';
45
54
  import { QueueEngine } from './queue-engine';
46
55
  import { TokenBucketRateLimiter } from './rate-limiter';
47
- import { saveUpload } from './uploads';
56
+ import { readBodyWithinLimit, saveUpload } from './uploads';
48
57
 
49
58
  export interface SliceMetadata {
50
59
  title?: string;
@@ -71,6 +80,7 @@ export interface DiscoveredSlice {
71
80
  routePath: string;
72
81
  rpcPath: string;
73
82
  webhookPath: string;
83
+ wsPath: string;
74
84
  filePath: string;
75
85
  actionFn?: (payload: unknown, dbOrCtx?: any, session?: SessionContext, extraCtx?: ActionContext) => Promise<any>;
76
86
  componentFn?: React.ComponentType<any>;
@@ -82,6 +92,10 @@ export interface DiscoveredSlice {
82
92
  metaFn?: (props: Record<string, unknown>, context: SliceLoaderContext) => SliceMetadata | Promise<SliceMetadata>;
83
93
  /** Optional Webhook handler receiving rawBody and ActionContext */
84
94
  webhookFn?: (event: WebhookEvent, ctx: ActionContext) => Promise<any>;
95
+ /** Optional SSR cache and ISR policy */
96
+ cacheConfig?: SliceCacheConfig;
97
+ /** Optional bidirectional WebSocket handler */
98
+ socketDef?: SliceSocketDefinition;
85
99
  }
86
100
 
87
101
  /**
@@ -121,6 +135,15 @@ const ERROR_STATUS: Array<{ pattern: RegExp; status: number }> = [
121
135
  { pattern: /_FAILED$/, status: 500 }
122
136
  ];
123
137
 
138
+ const RATE_LIMITED_PREFIXES = [
139
+ '/_synapse/rpc/',
140
+ '/_synapse/files/',
141
+ '/_synapse/webhooks/',
142
+ '/_synapse/sse/',
143
+ '/_synapse/images/',
144
+ '/_synapse/api/'
145
+ ];
146
+
124
147
  export const TURBO_ROUTER_SCRIPT = `(function() {
125
148
  if (window.__synapseNavInstalled) return;
126
149
  window.__synapseNavInstalled = true;
@@ -137,9 +160,9 @@ export const TURBO_ROUTER_SCRIPT = `(function() {
137
160
  curRoot.innerHTML = newRoot.innerHTML;
138
161
  document.title = doc.title;
139
162
  var newPropsScript = doc.querySelector('script[data-synapse-props]');
140
- if (newPropsScript) {
163
+ if (newPropsScript && newPropsScript.textContent) {
141
164
  try {
142
- eval(newPropsScript.textContent);
165
+ window.__SYNAPSE_PROPS__ = JSON.parse(newPropsScript.textContent);
143
166
  } catch (_) {}
144
167
  }
145
168
  if (push) history.pushState({}, '', href);
@@ -236,6 +259,150 @@ export function formatLogLine(entry: {
236
259
  return JSON.stringify({ ts: new Date().toISOString(), ...entry });
237
260
  }
238
261
 
262
+ /** Backstop ceiling for any request body: 12 MiB, above every per-route limit. */
263
+ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 12 * 1024 * 1024;
264
+
265
+ /** The runtime mode, from SYNAPSE_ENV or NODE_ENV. */
266
+ function runtimeMode(): string {
267
+ return (process.env.SYNAPSE_ENV ?? process.env.NODE_ENV ?? '').trim().toLowerCase();
268
+ }
269
+
270
+ /**
271
+ * True when the process declares a mode other than the development ones — an exact
272
+ * `production` spelling, but also `prod`, `staging` or any other value an operator
273
+ * clearly intends as a real deployment. An unset value is not production-like here:
274
+ * the request layer still requires the explicit development opt-in below.
275
+ */
276
+ export function isProductionLikeMode(): boolean {
277
+ const mode = runtimeMode();
278
+
279
+ if (!mode) {
280
+ return false;
281
+ }
282
+
283
+ return !['development', 'dev', 'test'].includes(mode);
284
+ }
285
+
286
+ /**
287
+ * Caller-supplied identity (x-user-id, x-user-roles, the roles cookie) is honoured
288
+ * only under an explicit development opt-in: `SYNAPSE_DEV_HEADERS=true`, or a
289
+ * development/test runtime mode. Any other state — unset, staging, production —
290
+ * resolves to an anonymous session, so a mistyped variable cannot grant identity,
291
+ * and the explicit flag is the acknowledgement an operator gives by name.
292
+ */
293
+ export function headerTrustEnabled(): boolean {
294
+ const explicit = (process.env.SYNAPSE_DEV_HEADERS ?? '').trim().toLowerCase();
295
+
296
+ if (explicit === 'true') {
297
+ return true;
298
+ }
299
+
300
+ if (explicit === 'false') {
301
+ return false;
302
+ }
303
+
304
+ if (isProductionLikeMode()) {
305
+ return false;
306
+ }
307
+
308
+ const mode = runtimeMode();
309
+
310
+ return mode === 'development' || mode === 'dev' || mode === 'test';
311
+ }
312
+
313
+ /**
314
+ * The process-level body ceiling: explicit config first, then
315
+ * SYNAPSE_MAX_REQUEST_BODY_BYTES, then the default.
316
+ */
317
+ export function maxRequestBodyBytes(config?: SynapseConfig): number {
318
+ const configured = config?.maxRequestBodyBytes;
319
+
320
+ if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) {
321
+ return Math.floor(configured);
322
+ }
323
+
324
+ const raw = Number(process.env.SYNAPSE_MAX_REQUEST_BODY_BYTES);
325
+
326
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_REQUEST_BODY_BYTES;
327
+ }
328
+
329
+ export interface ExternalFetchResult {
330
+ ok: boolean;
331
+ body?: Uint8Array;
332
+ contentType?: string;
333
+ status?: number;
334
+ code?: string;
335
+ }
336
+
337
+ /**
338
+ * Fetches a URL whose host has already passed `validateExternalUrl`, connecting to the
339
+ * address that was validated rather than resolving the hostname a second time: the URL
340
+ * is rewritten to the pinned address, the original Host header is preserved and TLS
341
+ * validation still checks the certificate against the real hostname (serverName).
342
+ *
343
+ * The response is read through the same streaming ceiling as every other body reader,
344
+ * the whole fetch is bounded by a timeout, and redirects are refused rather than
345
+ * followed (a hop would have to be revalidated, and refusing cannot be defeated).
346
+ */
347
+ export async function fetchPinnedExternal(
348
+ rawUrl: string,
349
+ resolvedIp: string | undefined,
350
+ options: { maxBytes: number; timeoutMs: number }
351
+ ): Promise<ExternalFetchResult> {
352
+ const parsed = new URL(rawUrl);
353
+ const hostname = parsed.hostname;
354
+ const pinnedHost = resolvedIp?.includes(':') ? `[${resolvedIp}]` : resolvedIp;
355
+ const port = parsed.port ? `:${parsed.port}` : '';
356
+ const target = pinnedHost ? `${parsed.protocol}//${pinnedHost}${port}${parsed.pathname}${parsed.search}` : rawUrl;
357
+
358
+ const controller = new AbortController();
359
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
360
+
361
+ try {
362
+ const response = await fetch(target, {
363
+ headers: { host: parsed.host },
364
+ redirect: 'manual',
365
+ signal: controller.signal,
366
+ ...(parsed.protocol === 'https:' ? { tls: { serverName: hostname } } : {})
367
+ } as RequestInit);
368
+
369
+ if (!response.ok) {
370
+ return { ok: false, status: 502, code: 'UPSTREAM_RESPONSE_FAILED' };
371
+ }
372
+
373
+ const body = await readBodyWithinLimit(response.body, options.maxBytes);
374
+
375
+ if (!body.ok) {
376
+ return { ok: false, status: 413, code: 'UPSTREAM_RESPONSE_TOO_LARGE' };
377
+ }
378
+
379
+ return {
380
+ ok: true,
381
+ body: body.value,
382
+ contentType: response.headers.get('content-type') || 'application/octet-stream'
383
+ };
384
+ } catch (err) {
385
+ const aborted = err instanceof Error && (err.name === 'AbortError' || err.message.includes('aborted'));
386
+
387
+ return aborted
388
+ ? { ok: false, status: 504, code: 'UPSTREAM_TIMEOUT' }
389
+ : { ok: false, status: 502, code: 'UPSTREAM_FETCH_FAILED' };
390
+ } finally {
391
+ clearTimeout(timer);
392
+ }
393
+ }
394
+
395
+ export interface ShutdownSummary {
396
+ /** Requests that were still being handled when the drain began and finished in time. */
397
+ drained: number;
398
+ /** Requests still running when the drain timeout expired; each received 503 DRAINING. */
399
+ aborted: number;
400
+ /** Jobs returned to the queue by the shutdown, exactly one attempt counted. */
401
+ jobsRequeued: number;
402
+ /** Jobs whose attempt budget was already exhausted; marked failed. */
403
+ jobsFailed: number;
404
+ }
405
+
239
406
  export class SynapseServer {
240
407
  private slices: Map<string, DiscoveredSlice> = new Map();
241
408
  private baseDir: string;
@@ -250,6 +417,29 @@ export class SynapseServer {
250
417
  private layoutComponent: React.ComponentType<any> | null = null;
251
418
  private domainLayouts: Map<string, React.ComponentType<any>> = new Map();
252
419
  private rateLimiter: TokenBucketRateLimiter;
420
+ private ssrCache: BoundedLruCache<{
421
+ html: string;
422
+ expiresAt: number;
423
+ staleUntil: number;
424
+ ttlSeconds: number;
425
+ swrSeconds: number;
426
+ tags: string[];
427
+ }>;
428
+ private revalidatingKeys = new Set<string>();
429
+
430
+ public invalidateCache(tags?: string[]): void {
431
+ if (!tags || tags.length === 0) {
432
+ this.ssrCache.clear();
433
+ return;
434
+ }
435
+ const tagSet = new Set(tags);
436
+ for (const [key, entry] of this.ssrCache.entries()) {
437
+ if (entry.tags.some((t) => tagSet.has(t))) {
438
+ this.ssrCache.delete(key);
439
+ }
440
+ }
441
+ }
442
+
253
443
  private metrics = {
254
444
  startTime: Date.now(),
255
445
  totalRequests: 0,
@@ -261,35 +451,159 @@ export class SynapseServer {
261
451
  };
262
452
  public port: number;
263
453
 
264
- constructor(baseDir: string = process.cwd(), port: number = 3000, db?: DatabaseClient) {
454
+ constructor(baseDir: string = process.cwd(), port: number = 3000, db?: DatabaseClient, config?: SynapseConfig) {
265
455
  this.baseDir = baseDir;
266
456
  this.port = port;
267
457
  this.db = db || getDatabase();
268
- this.rateLimiter = new TokenBucketRateLimiter();
458
+ if (config) {
459
+ this.config = { ...config };
460
+ }
461
+ this.rateLimiter = new TokenBucketRateLimiter({
462
+ maxBuckets: config?.rateLimit?.maxBuckets ?? 10_000,
463
+ capacity: config?.rateLimit?.capacity,
464
+ refillRate: config?.rateLimit?.refillRate
465
+ });
466
+ this.ssrCache = new BoundedLruCache(this.config.cache?.maxEntries || 1000);
269
467
  this.queueEngine = new QueueEngine({
270
468
  dbPath: path.join(this.baseDir, '.synapse/queue.sqlite'),
271
469
  logger: createDefaultLogger('QueueEngine')
272
470
  });
471
+
472
+ // Pending until shutdown reaches its drain deadline; every request races it.
473
+ this.drainSignal = new Promise<Response>((resolve) => {
474
+ this.drainSignalResolve = resolve;
475
+ });
273
476
  }
274
477
 
478
+ /**
479
+ * Resolves when in-flight requests must stop waiting — the drain deadline. It stays
480
+ * pending while the server is running, so every handler races it from the moment it
481
+ * begins and a request that is still running when the deadline fires is answered
482
+ * with 503 DRAINING instead of being cut off with no response.
483
+ */
484
+ private drainSignal: Promise<Response>;
485
+ private drainSignalResolve: ((response: Response) => void) | null = null;
486
+ private inFlightCount = 0;
487
+ private abortedCount = 0;
488
+ private drainedCount = 0;
489
+ private shutdownPromise: Promise<ShutdownSummary> | null = null;
490
+ private openStreams = new Set<ReadableStreamDefaultController<Uint8Array>>();
491
+
492
+ /** What a `stop()` call reports and logs: the observable result of the drain. */
493
+ private lastShutdownSummary: ShutdownSummary | null = null;
494
+
275
495
  /**
276
496
  * Gracefully shuts down the HTTP server and closes database connections.
497
+ *
498
+ * Waits for the promise Bun's own drain returns, raced against the configured
499
+ * timeout — the value is honoured instead of being replaced by a shorter fixed
500
+ * wait — and only then closes the rate limiter, the queue engine and the
501
+ * database, so a request that queries the database late in the drain still can.
502
+ * A second call is safe: it resolves with the same summary and closes nothing twice.
277
503
  */
278
- async stop(): Promise<void> {
279
- if (this.httpServer) {
280
- this.httpServer.stop(true);
281
- this.httpServer = null;
504
+ async stop(drainTimeoutMs = 5000): Promise<ShutdownSummary> {
505
+ if (this.shutdownPromise) {
506
+ return this.shutdownPromise;
282
507
  }
508
+
509
+ this.shutdownPromise = this.performShutdown(drainTimeoutMs);
510
+
511
+ return this.shutdownPromise;
512
+ }
513
+
514
+ private async performShutdown(drainTimeoutMs: number): Promise<ShutdownSummary> {
515
+ // No new background work is claimed from this point on: a job enqueued by an
516
+ // in-flight request stays pending for the next process.
517
+ this.queueEngine.stopClaiming();
518
+
519
+ const inFlightAtStart = this.inFlightCount;
520
+ const stopPromise: Promise<unknown> = this.httpServer ? this.httpServer.stop(false) : Promise.resolve();
521
+ this.httpServer = null;
522
+
523
+ let timedOut = false;
524
+
525
+ if (drainTimeoutMs > 0) {
526
+ let timer: ReturnType<typeof setTimeout> | undefined;
527
+ const deadline = new Promise<'timeout'>((resolve) => {
528
+ timer = setTimeout(() => resolve('timeout'), drainTimeoutMs);
529
+ });
530
+
531
+ // Fires only when the deadline expires: everything still outstanding is then
532
+ // answered with 503 DRAINING, deliberately, before persistence is closed.
533
+ deadline.then(() => {
534
+ this.abortedCount = this.inFlightCount;
535
+ this.closeOpenStreams();
536
+
537
+ this.drainSignalResolve?.(
538
+ Response.json(
539
+ { ok: false, error: 'DRAINING', message: 'O servidor está encerrando e não concluiu esta requisição.' },
540
+ { status: 503 }
541
+ )
542
+ );
543
+ });
544
+
545
+ // The wait is always bounded: a connection that never finishes producing its
546
+ // request must not hold the shutdown open past the configured timeout.
547
+ const outcome = await Promise.race([stopPromise.then(() => 'drained' as const), deadline]);
548
+
549
+ if (timer) {
550
+ clearTimeout(timer);
551
+ }
552
+
553
+ timedOut = outcome === 'timeout';
554
+ }
555
+ // A configured timeout of zero skips the wait entirely.
556
+
557
+ if (timedOut) {
558
+ // What outlived the deadline is terminated deliberately and reported; the
559
+ // race in the request path has already answered each one with 503 DRAINING.
560
+ this.closeOpenStreams();
561
+ }
562
+
563
+ this.drainedCount = inFlightAtStart - this.abortedCount;
564
+
565
+ const jobs = this.queueEngine.interruptRunningJobs();
566
+
283
567
  this.rateLimiter.close();
284
568
  this.queueEngine.close();
285
569
  await this.db.close?.();
286
570
  resetDatabaseInstance();
571
+
572
+ this.lastShutdownSummary = {
573
+ drained: Math.max(0, this.drainedCount),
574
+ aborted: this.abortedCount,
575
+ jobsRequeued: jobs.requeued,
576
+ jobsFailed: jobs.failed
577
+ };
578
+
579
+ return this.lastShutdownSummary;
580
+ }
581
+
582
+ private closeOpenStreams(): void {
583
+ for (const controller of this.openStreams) {
584
+ try {
585
+ controller.close();
586
+ } catch {
587
+ // already closed
588
+ }
589
+ }
590
+
591
+ this.openStreams.clear();
592
+ }
593
+
594
+ get shutdownSummary(): ShutdownSummary | null {
595
+ return this.lastShutdownSummary;
287
596
  }
288
597
 
289
598
  get database(): DatabaseClient {
290
599
  return this.db;
291
600
  }
292
601
 
602
+ /** How many slices were discovered and registered — the benchmark's own sanity check. */
603
+ get loadedSliceCount(): number {
604
+ return this.slices.size;
605
+ }
606
+
293
607
  get queue(): QueueEngine {
294
608
  return this.queueEngine;
295
609
  }
@@ -306,7 +620,13 @@ export class SynapseServer {
306
620
  rpcSuccessCount: this.metrics.rpcSuccessCount,
307
621
  rpcErrorCount: this.metrics.rpcErrorCount,
308
622
  ssrRenderCount: this.metrics.ssrRenderCount,
309
- staticFileCount: this.metrics.staticFileCount
623
+ staticFileCount: this.metrics.staticFileCount,
624
+ /** Requests that have begun handling and not yet produced a response. */
625
+ inFlightRequests: this.inFlightCount,
626
+ /** Open event streams: reported apart from drainable work. */
627
+ openStreams: this.openStreams.size,
628
+ shuttingDown: this.shutdownPromise !== null,
629
+ shutdown: this.lastShutdownSummary
310
630
  };
311
631
  }
312
632
 
@@ -314,14 +634,17 @@ export class SynapseServer {
314
634
  return this.config;
315
635
  }
316
636
 
317
- private createActionContext(sliceName: string, session: SessionContext): ActionContext {
637
+ private createActionContext(sliceKey: string, session: SessionContext): ActionContext {
318
638
  return createActionContext({
319
639
  db: this.db,
320
640
  session,
321
641
  tenantId: session.tenantId,
322
642
  services: (this.config.services as Record<string, unknown>) || {},
323
- logger: createDefaultLogger(sliceName),
324
- enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options)
643
+ logger: createDefaultLogger(sliceKey),
644
+ // Publishing is owner-checked: only the slice that declared a topic may write to it.
645
+ sliceOwner: sliceKey,
646
+ enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options),
647
+ invalidateCache: (tags) => this.invalidateCache(tags)
325
648
  });
326
649
  }
327
650
 
@@ -409,6 +732,8 @@ export class SynapseServer {
409
732
  let loaderFn: any = null;
410
733
  let webhookFn: any = null;
411
734
  let metaFn: any = null;
735
+ let cacheConfig: any = null;
736
+ let socketDef: any = null;
412
737
 
413
738
  for (const [exportName, val] of Object.entries(mod)) {
414
739
  if (
@@ -420,16 +745,29 @@ export class SynapseServer {
420
745
  ) {
421
746
  this.queueEngine.registerJob(val as any);
422
747
  }
748
+ if (isCache(exportName) && val && typeof val === 'object') {
749
+ cacheConfig = val;
750
+ }
751
+ if (isSocket(exportName) && val && typeof val === 'object') {
752
+ socketDef = val;
753
+ }
423
754
  if (typeof val !== 'function') {
424
755
  continue;
425
756
  }
426
- if (isAction(exportName)) {
757
+ if (exportName === 'default' && typeof val === 'function') {
758
+ componentFn = val;
759
+ componentExport = 'default';
760
+ } else if (isAction(exportName)) {
427
761
  actionFn = val;
428
762
  } else if (isLoader(exportName)) {
429
763
  loaderFn = val;
430
764
  } else if (isComponent(exportName)) {
431
- componentFn = val;
432
- componentExport = exportName;
765
+ if (componentExport !== 'default') {
766
+ if (!componentFn || exportName.endsWith('View')) {
767
+ componentFn = val;
768
+ componentExport = exportName;
769
+ }
770
+ }
433
771
  } else if (isWebhook(exportName)) {
434
772
  webhookFn = val;
435
773
  } else if (isMeta(exportName)) {
@@ -444,13 +782,16 @@ export class SynapseServer {
444
782
  routePath,
445
783
  rpcPath,
446
784
  webhookPath: `/_synapse/webhooks/${domain}/${name}`,
785
+ wsPath: `/_synapse/ws/${domain}/${name}`,
447
786
  filePath: file,
448
787
  actionFn,
449
788
  componentFn,
450
789
  componentExport,
451
790
  loaderFn,
452
791
  metaFn,
453
- webhookFn
792
+ webhookFn,
793
+ cacheConfig,
794
+ socketDef
454
795
  });
455
796
  } catch (err) {
456
797
  const message = err instanceof Error ? err.message : String(err);
@@ -462,6 +803,13 @@ export class SynapseServer {
462
803
  return this.slices.size;
463
804
  }
464
805
 
806
+ /**
807
+ * Registers a slice directly on this server instance (useful for programmatic slices and testing).
808
+ */
809
+ public registerSlice(slice: DiscoveredSlice): void {
810
+ this.slices.set(slice.key, slice);
811
+ }
812
+
465
813
  /**
466
814
  * `<domain>/<name>` resolves directly. A bare name resolves only while it is
467
815
  * unique across domains; ambiguity is an explicit conflict, never a guess.
@@ -519,6 +867,57 @@ export class SynapseServer {
519
867
  };
520
868
  }
521
869
 
870
+ /**
871
+ * Client identity for rate limiting. Forwarding headers are honoured only when the
872
+ * deployment explicitly declares a trusted proxy (SYNAPSE_TRUST_PROXY=true or
873
+ * config.trustProxy), so a direct client rotating X-Forwarded-For cannot mint a
874
+ * fresh allowance out of thin air.
875
+ */
876
+ private clientIdentity(req: Request): string {
877
+ const peer = this.httpServer?.requestIP(req)?.address || '127.0.0.1';
878
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY === 'true' || this.config.trustProxy === true;
879
+
880
+ if (!trustProxy) {
881
+ return peer;
882
+ }
883
+
884
+ return req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || peer;
885
+ }
886
+
887
+ /**
888
+ * Consumes one token for the request's client identity. Returns a 429 response when
889
+ * the allowance is exhausted, or null when the request may proceed.
890
+ */
891
+ private rateLimitRefusal(req: Request, pathname: string, startedAt: number): Response | null {
892
+ const rateResult = this.rateLimiter.consume(this.clientIdentity(req));
893
+
894
+ if (rateResult.allowed) {
895
+ return null;
896
+ }
897
+
898
+ const cors = this.corsHeaders(req);
899
+ this.logRequest(req, pathname, 429, startedAt);
900
+
901
+ return new Response(
902
+ JSON.stringify({
903
+ ok: false,
904
+ error: 'RATE_LIMIT_EXCEEDED',
905
+ message: 'Muitas requisições. Tente novamente mais tarde.'
906
+ }),
907
+ {
908
+ status: 429,
909
+ headers: {
910
+ 'Content-Type': 'application/json; charset=utf-8',
911
+ 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000)),
912
+ 'X-RateLimit-Limit': String(rateResult.limit),
913
+ 'X-RateLimit-Remaining': String(rateResult.remaining),
914
+ 'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateResult.resetMs) / 1000)),
915
+ ...(cors ?? {})
916
+ }
917
+ }
918
+ );
919
+ }
920
+
522
921
  /** Records request metrics and writes one JSON line per request when SYNAPSE_LOG=json. */
523
922
  private logRequest(
524
923
  request: Request,
@@ -578,17 +977,26 @@ export class SynapseServer {
578
977
 
579
978
  if (secret) {
580
979
  if (!token) {
581
- return AnonymousSession(tenantId);
980
+ return AnonymousSession();
582
981
  }
583
982
  const verified = verifySessionToken(token, secret);
983
+
984
+ // The tenant comes exclusively from the verified claims: with a secret
985
+ // configured, neither the tenant header nor the subdomain can supply one.
584
986
  return verified.ok
585
987
  ? createSession({
586
988
  userId: verified.value.userId,
587
- tenantId: (verified.value as { tenantId?: string }).tenantId || tenantId,
989
+ tenantId: verified.value.tenantId,
588
990
  roles: verified.value.roles,
589
991
  token
590
992
  })
591
- : AnonymousSession(tenantId);
993
+ : AnonymousSession();
994
+ }
995
+
996
+ // Sem segredo, a identidade forjável por header só é aceita sob opt-in explícito
997
+ // de desenvolvimento; qualquer outro estado resolve para anônimo.
998
+ if (!headerTrustEnabled()) {
999
+ return AnonymousSession(tenantId);
592
1000
  }
593
1001
 
594
1002
  // Modo de desenvolvimento (sem segredo configurado):
@@ -614,6 +1022,60 @@ export class SynapseServer {
614
1022
  return AnonymousSession(tenantId);
615
1023
  }
616
1024
 
1025
+ /** Signature -> memoised revocation verdict, bounded by TTL and entry cap. */
1026
+ private revocationMemo = new Map<string, { revoked: boolean; checkedAt: number }>();
1027
+
1028
+ /**
1029
+ * The session for a request, with revocation consulted on every request. A
1030
+ * signature is queried against the persisted store at most once per TTL window,
1031
+ * so the common case costs nothing; the in-process set short-circuits it entirely.
1032
+ */
1033
+ async requestSession(request: Request): Promise<SessionContext> {
1034
+ const session = this.sessionFrom(request);
1035
+
1036
+ if (!session.isAuthenticated || !session.token) {
1037
+ return session;
1038
+ }
1039
+
1040
+ return (await this.isTokenRevoked(session.token)) ? AnonymousSession() : session;
1041
+ }
1042
+
1043
+ private async isTokenRevoked(token: string): Promise<boolean> {
1044
+ if (isSessionTokenRevoked(token)) {
1045
+ return true;
1046
+ }
1047
+
1048
+ const signature = token.split('.')[1];
1049
+
1050
+ if (!signature) {
1051
+ return true;
1052
+ }
1053
+
1054
+ const ttlMs = Number(process.env.SYNAPSE_REVOCATION_MEMO_TTL_MS ?? '');
1055
+ const ttl = Number.isFinite(ttlMs) && ttlMs >= 0 ? ttlMs : 30_000;
1056
+ const cached = this.revocationMemo.get(signature);
1057
+
1058
+ if (cached && Date.now() - cached.checkedAt < ttl) {
1059
+ return cached.revoked;
1060
+ }
1061
+
1062
+ const revoked = await isSessionTokenRevokedInDb(this.db, token);
1063
+
1064
+ while (this.revocationMemo.size >= 5000) {
1065
+ const oldest = this.revocationMemo.keys().next().value;
1066
+
1067
+ if (oldest === undefined) {
1068
+ break;
1069
+ }
1070
+
1071
+ this.revocationMemo.delete(oldest);
1072
+ }
1073
+
1074
+ this.revocationMemo.set(signature, { revoked, checkedAt: Date.now() });
1075
+
1076
+ return revoked;
1077
+ }
1078
+
617
1079
  /**
618
1080
  * Builds the browser bundle for a slice from the splitter's client artifact.
619
1081
  *
@@ -656,7 +1118,8 @@ export class SynapseServer {
656
1118
  filePath: slice.filePath,
657
1119
  rpcPath: slice.rpcPath
658
1120
  },
659
- this.baseDir
1121
+ this.baseDir,
1122
+ { vendorPackages: this.config.compiler?.vendorPackages }
660
1123
  );
661
1124
 
662
1125
  if (!built.ok) {
@@ -671,28 +1134,352 @@ export class SynapseServer {
671
1134
  }
672
1135
 
673
1136
  /**
674
- * Returns stylesheet links, respecting SYNAPSE_DISABLE_CDN and detecting public/synapse.css
1137
+ * The third-party origins, opt-in only. Enabling them without an integrity hash
1138
+ * emits nothing: an unpinned third-party script (or stylesheet) in an origin that
1139
+ * holds a script-readable session cookie is the risk this switch exists to gate.
1140
+ */
1141
+ private thirdPartyOrigins(): { enabled: boolean; script?: string; fontStyles?: string; fontFiles?: string } {
1142
+ const enabled =
1143
+ this.config.cdn?.enabled === true ||
1144
+ process.env.SYNAPSE_ENABLE_CDN === 'true' ||
1145
+ process.env.SYNAPSE_ENABLE_CDN === '1';
1146
+
1147
+ if (!enabled) {
1148
+ return { enabled: false };
1149
+ }
1150
+
1151
+ return {
1152
+ enabled: true,
1153
+ script: this.config.cdn?.scriptIntegrity || process.env.SYNAPSE_CDN_INTEGRITY || undefined,
1154
+ fontStyles: this.config.cdn?.fontIntegrity || process.env.SYNAPSE_FONTS_INTEGRITY || undefined,
1155
+ fontFiles: 'https://fonts.gstatic.com'
1156
+ };
1157
+ }
1158
+
1159
+ /**
1160
+ * The Content-Security-Policy every HTML response carries. An operator-supplied
1161
+ * policy replaces it (never weakened); otherwise the policy is same-origin, with
1162
+ * third-party origins appearing only when explicitly enabled and pinned.
1163
+ */
1164
+ private contentSecurityPolicy(): string {
1165
+ const operatorPolicy = this.config.contentSecurityPolicy || process.env.SYNAPSE_CSP;
1166
+
1167
+ if (operatorPolicy) {
1168
+ return operatorPolicy;
1169
+ }
1170
+
1171
+ const thirdParty = this.thirdPartyOrigins();
1172
+ const scriptSources = ["'self'", "'unsafe-inline'"];
1173
+ const styleSources = ["'self'", "'unsafe-inline'"];
1174
+ const fontSources = ["'self'", 'data:'];
1175
+
1176
+ if (thirdParty.script) {
1177
+ scriptSources.push('https://cdn.tailwindcss.com');
1178
+ }
1179
+
1180
+ if (thirdParty.fontStyles) {
1181
+ styleSources.push('https://fonts.googleapis.com');
1182
+ fontSources.push('https://fonts.gstatic.com');
1183
+ }
1184
+
1185
+ return [
1186
+ "default-src 'self'",
1187
+ `script-src ${scriptSources.join(' ')}`,
1188
+ `style-src ${styleSources.join(' ')}`,
1189
+ "img-src 'self' data: https:",
1190
+ `font-src ${fontSources.join(' ')}`,
1191
+ "connect-src 'self'",
1192
+ "object-src 'none'",
1193
+ "frame-ancestors 'none'",
1194
+ "base-uri 'self'",
1195
+ "form-action 'self'"
1196
+ ].join('; ');
1197
+ }
1198
+
1199
+ /** The baseline headers every response carries — routed, unrouted, static or error. */
1200
+ private baselineHeaders(req: Request): Record<string, string> {
1201
+ const headers: Record<string, string> = {
1202
+ 'X-Content-Type-Options': 'nosniff',
1203
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
1204
+ 'X-Frame-Options': 'DENY',
1205
+ 'Content-Security-Policy': this.contentSecurityPolicy()
1206
+ };
1207
+
1208
+ const forwardedProto = req.headers.get('x-forwarded-proto');
1209
+ const trustProxy = process.env.SYNAPSE_TRUST_PROXY === 'true' || this.config.trustProxy === true;
1210
+ const isTls =
1211
+ new URL(req.url).protocol === 'https:' || (trustProxy && forwardedProto?.split(',')[0]?.trim() === 'https');
1212
+
1213
+ if (isTls) {
1214
+ headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
1215
+ }
1216
+
1217
+ return headers;
1218
+ }
1219
+
1220
+ /** Adds the baseline headers to a response without overriding what it already sets. */
1221
+ private withBaselineHeaders(response: Response, req: Request): Response {
1222
+ for (const [name, value] of Object.entries(this.baselineHeaders(req))) {
1223
+ if (!response.headers.has(name)) {
1224
+ response.headers.set(name, value);
1225
+ }
1226
+ }
1227
+
1228
+ // The response is returned as it came, so a `Bun.file` body keeps its
1229
+ // zero-copy send instead of being re-wrapped as a stream.
1230
+ return response;
1231
+ }
1232
+
1233
+ /**
1234
+ * A realtime refusal: the same machine-readable shape on both transports, with the
1235
+ * code always drawn from the set the machine contract enumerates.
1236
+ */
1237
+ private realtimeRefusal(
1238
+ code: RealtimeRejectionCode,
1239
+ status: number,
1240
+ message: string,
1241
+ cors?: Record<string, string> | null
1242
+ ): Response {
1243
+ return Response.json({ ok: false, error: code, message }, { status, headers: cors ?? undefined });
1244
+ }
1245
+
1246
+ /** Concurrent realtime connections held per client identity. */
1247
+ private realtimeConnections = new Map<string, number>();
1248
+
1249
+ private releaseRealtimeConnection(clientKey: string): void {
1250
+ const open = this.realtimeConnections.get(clientKey) ?? 0;
1251
+
1252
+ if (open <= 1) {
1253
+ this.realtimeConnections.delete(clientKey);
1254
+ } else {
1255
+ this.realtimeConnections.set(clientKey, open - 1);
1256
+ }
1257
+ }
1258
+
1259
+ private realtimeBounds(): { maxTopics: number; maxSubscriptions: number; maxConnectionsPerClient: number } {
1260
+ return {
1261
+ maxTopics: this.config.realtime?.maxTopics ?? 1000,
1262
+ maxSubscriptions: this.config.realtime?.maxSubscriptions ?? 1000,
1263
+ maxConnectionsPerClient: this.config.realtime?.maxConnectionsPerClient ?? 8
1264
+ };
1265
+ }
1266
+
1267
+ /**
1268
+ * The origin check for a WebSocket upgrade, fail-closed: an allow-list must be
1269
+ * configured and must contain the request's origin (or the wildcard). An absent
1270
+ * origin and the opaque `null` origin are refused. The one carve-out is a loopback
1271
+ * origin, so local development works without a production-grade allow-list.
1272
+ */
1273
+ private webSocketOriginAllowed(req: Request): boolean {
1274
+ const origin = req.headers.get('origin');
1275
+
1276
+ if (!origin || origin === 'null') {
1277
+ return false;
1278
+ }
1279
+
1280
+ if (this.isLoopbackOrigin(origin)) {
1281
+ return true;
1282
+ }
1283
+
1284
+ const allowed = (process.env.SYNAPSE_ALLOWED_ORIGINS ?? '')
1285
+ .split(',')
1286
+ .map((entry) => entry.trim())
1287
+ .filter((entry) => entry.length > 0);
1288
+
1289
+ return allowed.includes('*') || allowed.includes(origin);
1290
+ }
1291
+
1292
+ private isLoopbackOrigin(origin: string): boolean {
1293
+ try {
1294
+ const parsed = new URL(origin);
1295
+
1296
+ return (
1297
+ parsed.hostname === 'localhost' ||
1298
+ parsed.hostname === '127.0.0.1' ||
1299
+ parsed.hostname === '::1' ||
1300
+ parsed.hostname === '[::1]'
1301
+ );
1302
+ } catch {
1303
+ return false;
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * The response for an unexpected internal failure: a stable code plus a
1309
+ * correlation identifier that also appears on the log record. Outside development
1310
+ * the exception text is never part of the body — it goes to the log only.
1311
+ */
1312
+ private internalFailure(err: unknown, pathname: string, cors?: Record<string, string> | null): Response {
1313
+ const correlationId = crypto.randomUUID();
1314
+ const message = err instanceof Error ? err.message : String(err);
1315
+
1316
+ console.error(`[SynapseServer] Falha interna [${correlationId}] em ${pathname}: ${message}`);
1317
+
1318
+ return Response.json(
1319
+ {
1320
+ ok: false,
1321
+ error: 'INTERNAL_ERROR',
1322
+ correlationId,
1323
+ ...(isProductionLikeMode() ? {} : { message })
1324
+ },
1325
+ { status: 500, headers: cors ?? undefined }
1326
+ );
1327
+ }
1328
+
1329
+ /**
1330
+ * Returns stylesheet links. The application's own stylesheet always wins; nothing
1331
+ * third-party is referenced unless explicitly enabled and integrity-pinned.
675
1332
  */
676
1333
  private renderStylesheets(): string {
677
- const disableCdn = process.env.SYNAPSE_DISABLE_CDN === 'true' || process.env.SYNAPSE_DISABLE_CDN === '1';
678
1334
  const localCssPath = path.join(this.baseDir, 'public', 'synapse.css');
679
- const hasLocalCss = fs.existsSync(localCssPath);
1335
+
1336
+ if (fs.existsSync(localCssPath)) {
1337
+ return ' <link rel="stylesheet" href="/synapse.css">';
1338
+ }
1339
+
1340
+ const thirdParty = this.thirdPartyOrigins();
1341
+
1342
+ if (!thirdParty.enabled) {
1343
+ return '';
1344
+ }
680
1345
 
681
1346
  const elements: string[] = [];
682
- if (!disableCdn) {
683
- elements.push(' <script src="https://cdn.tailwindcss.com"></script>');
1347
+
1348
+ if (thirdParty.script) {
1349
+ elements.push(
1350
+ ` <script src="https://cdn.tailwindcss.com" integrity="${thirdParty.script}" crossorigin="anonymous"></script>`
1351
+ );
1352
+ } else {
1353
+ console.warn(
1354
+ '[SynapseServer] CDN habilitado sem um hash de integridade (SYNAPSE_CDN_INTEGRITY): o script de terceiros não será emitido.'
1355
+ );
1356
+ }
1357
+
1358
+ if (thirdParty.fontStyles) {
684
1359
  elements.push(' <link rel="preconnect" href="https://fonts.googleapis.com">');
685
1360
  elements.push(' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
686
1361
  elements.push(
687
- ' <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">'
1362
+ ` <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" integrity="${thirdParty.fontStyles}" crossorigin="anonymous">`
688
1363
  );
689
1364
  }
690
- if (hasLocalCss) {
691
- elements.push(' <link rel="stylesheet" href="/synapse.css">');
692
- }
1365
+
693
1366
  return elements.join('\n');
694
1367
  }
695
1368
 
1369
+ /**
1370
+ * Renders the slice SSR component tree and packages the complete HTML shell
1371
+ */
1372
+ private async renderSliceHtml(
1373
+ slice: DiscoveredSlice,
1374
+ req: Request,
1375
+ url: URL,
1376
+ matchedLocale?: string
1377
+ ): Promise<{
1378
+ html: string;
1379
+ dataProps: Record<string, unknown>;
1380
+ meta?: SliceMetadata;
1381
+ hasError?: boolean;
1382
+ error?: string;
1383
+ }> {
1384
+ const session = await this.requestSession(req);
1385
+ const actionCtx = this.createActionContext(slice.key, session);
1386
+ const params = Object.fromEntries(url.searchParams.entries());
1387
+ const dataProps: Record<string, unknown> = {
1388
+ ...params,
1389
+ ...(matchedLocale ? { locale: matchedLocale } : {})
1390
+ };
1391
+ let loaderError: string | null = null;
1392
+
1393
+ const loaderCtx: SliceLoaderContext = {
1394
+ url: url.toString(),
1395
+ params,
1396
+ db: this.db,
1397
+ session,
1398
+ ctx: actionCtx,
1399
+ services: (this.config.services as Record<string, unknown>) || {},
1400
+ locale: matchedLocale
1401
+ };
1402
+
1403
+ if (slice.loaderFn) {
1404
+ try {
1405
+ const loaded = await slice.loaderFn(loaderCtx);
1406
+ Object.assign(dataProps, loaded);
1407
+ } catch (err) {
1408
+ loaderError = err instanceof Error ? err.message : String(err);
1409
+ console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
1410
+ }
1411
+ }
1412
+
1413
+ let meta: SliceMetadata | undefined;
1414
+ if (slice.metaFn && !loaderError) {
1415
+ try {
1416
+ meta = await slice.metaFn(dataProps, loaderCtx);
1417
+ } catch (err) {
1418
+ console.error(`[SynapseServer] sliceMeta de ${slice.key} falhou:`, err);
1419
+ }
1420
+ }
1421
+
1422
+ const renderProps: Record<string, unknown> = {
1423
+ ...dataProps,
1424
+ ...(slice.actionFn
1425
+ ? {
1426
+ onSubmitAction: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx),
1427
+ action: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx)
1428
+ }
1429
+ : {})
1430
+ };
1431
+
1432
+ let contentHtml: string;
1433
+ let hasError = false;
1434
+ let renderError: string | null = loaderError;
1435
+
1436
+ const isProduction = isProductionLikeMode();
1437
+ if (loaderError) {
1438
+ hasError = true;
1439
+ console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
1440
+ const displayMsg = isProduction ? 'Ocorreu um erro interno ao carregar os dados.' : escapeHtml(loaderError);
1441
+ contentHtml = `<div class="text-rose-400">Falha no loader de ${escapeHtml(slice.key)}: ${displayMsg}</div>`;
1442
+ } else if (slice.componentFn) {
1443
+ try {
1444
+ const sliceEl = React.createElement(slice.componentFn, renderProps);
1445
+ const domainLayout = this.domainLayouts.get(slice.domain);
1446
+ const treeWithDomainLayout = domainLayout
1447
+ ? React.createElement(domainLayout, { session, url: url.toString() }, sliceEl)
1448
+ : sliceEl;
1449
+ const treeWithLayout = this.layoutComponent
1450
+ ? React.createElement(this.layoutComponent, { session, url: url.toString() }, treeWithDomainLayout)
1451
+ : treeWithDomainLayout;
1452
+ const rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
1453
+ contentHtml = renderToString(rootEl);
1454
+ } catch (e: any) {
1455
+ hasError = true;
1456
+ const errMessage = e instanceof Error ? e.message : String(e);
1457
+ console.error(`[SynapseServer] Erro na renderização SSR de ${slice.key}: ${errMessage}`);
1458
+ renderError = errMessage;
1459
+ const displayMsg = isProduction ? 'Ocorreu um erro interno durante a renderização.' : escapeHtml(errMessage);
1460
+ contentHtml = `<div class="text-rose-400">Erro na renderização SSR: ${displayMsg}</div>`;
1461
+ }
1462
+ } else {
1463
+ contentHtml = '<div class="text-slate-400">Nenhum componente de UI exportado.</div>';
1464
+ }
1465
+
1466
+ const clientUrl = hasError ? null : await this.ensureClientBundle(slice);
1467
+
1468
+ const hasCustomLayout = Boolean(this.layoutComponent || this.domainLayouts.get(slice.domain));
1469
+ const html = this.renderHtmlShell(
1470
+ meta?.title || slice.name,
1471
+ contentHtml,
1472
+ slice.name,
1473
+ serializeClientProps(dataProps),
1474
+ clientUrl,
1475
+ meta,
1476
+ matchedLocale || 'pt-BR',
1477
+ hasCustomLayout
1478
+ );
1479
+
1480
+ return { html, dataProps, meta, hasError, error: renderError || undefined };
1481
+ }
1482
+
696
1483
  /**
697
1484
  * Generates the SSR HTML Shell with Inter font, Tailwind and client hydration
698
1485
  */
@@ -737,6 +1524,22 @@ export class SynapseServer {
737
1524
  }
738
1525
  const metaSection = metaTags.length > 0 ? `\n${metaTags.join('\n')}` : '';
739
1526
 
1527
+ const vendorImports: Record<string, string> = {
1528
+ react: '/_synapse/client/_vendor.js',
1529
+ 'react/jsx-runtime': '/_synapse/client/_vendor.js',
1530
+ 'react/jsx-dev-runtime': '/_synapse/client/_vendor.js',
1531
+ 'react-dom': '/_synapse/client/_vendor.js',
1532
+ 'react-dom/client': '/_synapse/client/_vendor.js',
1533
+ 'synapsejs/client': '/_synapse/client/_vendor.js',
1534
+ '@ismaelsoilet/synapsejs/client': '/_synapse/client/_vendor.js'
1535
+ };
1536
+ if (this.config.compiler?.vendorPackages) {
1537
+ for (const pkg of this.config.compiler.vendorPackages) {
1538
+ vendorImports[pkg] = '/_synapse/client/_vendor.js';
1539
+ }
1540
+ }
1541
+ const importMapScript = ` <script type="importmap">\n ${JSON.stringify({ imports: vendorImports }, null, 4).split('\n').join('\n ')}\n </script>`;
1542
+
740
1543
  if (hasLayout) {
741
1544
  return `<!DOCTYPE html>
742
1545
  <html lang="${escapeHtml(locale)}" class="min-h-full">
@@ -745,19 +1548,7 @@ export class SynapseServer {
745
1548
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
746
1549
  <title>${pageTitle}</title>${metaSection}
747
1550
  ${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>
1551
+ ${importMapScript}
761
1552
  <script type="module" src="/_synapse/client/_vendor.js"></script>
762
1553
  <style>
763
1554
  body { font-family: 'Inter', sans-serif; }
@@ -767,7 +1558,8 @@ ${this.renderStylesheets()}
767
1558
  <body class="min-h-full flex flex-col">
768
1559
  <div id="synapse-root" class="min-h-full flex-1 flex flex-col">${contentHtml}</div>
769
1560
 
770
- <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
1561
+ <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>
771
1563
  ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
772
1564
 
773
1565
  <script type="module" src="/_synapse/turbo-router.js"></script>
@@ -782,19 +1574,7 @@ ${this.renderStylesheets()}
782
1574
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
783
1575
  <title>${pageTitle}</title>${metaSection}
784
1576
  ${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>
1577
+ ${importMapScript}
798
1578
  <script type="module" src="/_synapse/client/_vendor.js"></script>
799
1579
  <style>
800
1580
  body { font-family: 'Inter', sans-serif; }
@@ -835,7 +1615,8 @@ ${this.renderStylesheets()}
835
1615
  <div id="synapse-root" class="bg-slate-900/80 border border-slate-800 rounded-xl p-6 shadow-2xl backdrop-blur">${contentHtml}</div>
836
1616
 
837
1617
  <!-- Props de hidratacao e a entrada de cliente gerada -->
838
- <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
1618
+ <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>
839
1620
  ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
840
1621
 
841
1622
  <!-- Turbo Morphing SPA Navigation (<1.5kb, Zero Dependencies) -->
@@ -947,6 +1728,10 @@ ${this.renderStylesheets()}
947
1728
  this.config = {
948
1729
  ...loadedConfig,
949
1730
  ...this.config,
1731
+ compiler: {
1732
+ ...(loadedConfig.compiler || {}),
1733
+ ...(this.config?.compiler || {})
1734
+ },
950
1735
  plugins: [...(loadedConfig.plugins || []), ...(this.config?.plugins || [])]
951
1736
  };
952
1737
 
@@ -964,14 +1749,80 @@ ${this.renderStylesheets()}
964
1749
  }
965
1750
  }
966
1751
 
1752
+ /**
1753
+ * Process-level ceiling on any request body. It is the backstop behind the
1754
+ * per-route limits (RPC 5 MiB, webhook 10 MiB, upload 5 MiB): every body-reading
1755
+ * path is bounded even before its own guard runs, so no single request can
1756
+ * exhaust process memory.
1757
+ */
1758
+ const processBodyCeiling = maxRequestBodyBytes(this.config);
1759
+
967
1760
  this.httpServer = Bun.serve({
968
1761
  port: this.port,
1762
+ maxRequestBodySize: processBodyCeiling,
1763
+ websocket: {
1764
+ open: async (ws: any) => {
1765
+ const socketDef = ws.data?.socketDef;
1766
+ if (socketDef?.onOpen) {
1767
+ try {
1768
+ await socketDef.onOpen(ws);
1769
+ } catch (err) {
1770
+ console.error(`[SynapseServer] Erro no onOpen do WebSocket (${ws.data?.sliceKey}):`, err);
1771
+ }
1772
+ }
1773
+ },
1774
+ message: async (ws: any, message: any) => {
1775
+ const socketDef = ws.data?.socketDef;
1776
+ if (socketDef?.onMessage) {
1777
+ try {
1778
+ let parsed = message;
1779
+ if (typeof message === 'string') {
1780
+ try {
1781
+ parsed = JSON.parse(message);
1782
+ } catch {
1783
+ // mantém texto puro
1784
+ }
1785
+ }
1786
+ await socketDef.onMessage(ws, parsed);
1787
+ } catch (err) {
1788
+ console.error(`[SynapseServer] Erro no onMessage do WebSocket (${ws.data?.sliceKey}):`, err);
1789
+ }
1790
+ }
1791
+ },
1792
+ close: async (ws: any, code: number, reason: string) => {
1793
+ if (ws.data?.clientKey) {
1794
+ this.releaseRealtimeConnection(ws.data.clientKey);
1795
+ }
1796
+
1797
+ const socketDef = ws.data?.socketDef;
1798
+ if (socketDef?.onClose) {
1799
+ try {
1800
+ await socketDef.onClose(ws, code, reason);
1801
+ } catch (err) {
1802
+ console.error(`[SynapseServer] Erro no onClose do WebSocket (${ws.data?.sliceKey}):`, err);
1803
+ }
1804
+ }
1805
+ }
1806
+ },
969
1807
  fetch: async (req: Request) => {
1808
+ // Every request that has begun handling and has not produced a response is
1809
+ // outstanding work the drain waits for. An unbounded event stream is not:
1810
+ // it is tracked separately and closed when shutdown begins.
1811
+ this.inFlightCount++;
1812
+ let released = false;
1813
+ const releaseInFlight = () => {
1814
+ if (!released) {
1815
+ released = true;
1816
+ this.inFlightCount--;
1817
+ }
1818
+ };
1819
+
970
1820
  if (this.config.plugins) {
971
1821
  for (const plugin of this.config.plugins) {
972
1822
  if (plugin.onRequest) {
973
1823
  const intercepted = await plugin.onRequest(req);
974
1824
  if (intercepted instanceof Response) {
1825
+ releaseInFlight();
975
1826
  return intercepted;
976
1827
  }
977
1828
  }
@@ -983,6 +1834,76 @@ ${this.renderStylesheets()}
983
1834
  const pathname = url.pathname;
984
1835
  const startedAt = performance.now();
985
1836
 
1837
+ // WebSocket Upgrade for slices: /_synapse/ws/:domain/:name
1838
+ if (pathname.startsWith('/_synapse/ws/')) {
1839
+ // Fail-closed: no allow-list means no upgrade, with a loopback carve-out
1840
+ // for local development. An absent or opaque origin is refused too.
1841
+ if (!this.webSocketOriginAllowed(req)) {
1842
+ this.logRequest(req, pathname, 403, startedAt);
1843
+ return this.realtimeRefusal(
1844
+ 'ORIGIN_NOT_ALLOWED',
1845
+ 403,
1846
+ 'Origem não permitida para conexão WebSocket.',
1847
+ this.corsHeaders(req)
1848
+ );
1849
+ }
1850
+
1851
+ const rateRefusal = this.rateLimitRefusal(req, pathname, startedAt);
1852
+
1853
+ if (rateRefusal) {
1854
+ return rateRefusal;
1855
+ }
1856
+
1857
+ const wsBounds = this.realtimeBounds();
1858
+ const wsClientKey = this.clientIdentity(req);
1859
+ const wsOpen = this.realtimeConnections.get(wsClientKey) ?? 0;
1860
+
1861
+ if (wsOpen >= wsBounds.maxConnectionsPerClient) {
1862
+ this.logRequest(req, pathname, 429, startedAt);
1863
+ return this.realtimeRefusal(
1864
+ 'REALTIME_LIMIT_EXCEEDED',
1865
+ 429,
1866
+ 'Limite de conexões simultâneas por cliente atingido.'
1867
+ );
1868
+ }
1869
+
1870
+ this.realtimeConnections.set(wsClientKey, wsOpen + 1);
1871
+
1872
+ const target = pathname.slice('/_synapse/ws/'.length);
1873
+ const resolved = this.resolveSlice(target);
1874
+ if (!resolved.ok) {
1875
+ this.releaseRealtimeConnection(wsClientKey);
1876
+ return new Response(JSON.stringify({ error: resolved.message }), {
1877
+ status: resolved.status,
1878
+ headers: { 'Content-Type': 'application/json' }
1879
+ });
1880
+ }
1881
+ const socketDef = resolved.slice.socketDef;
1882
+ if (!socketDef) {
1883
+ this.releaseRealtimeConnection(wsClientKey);
1884
+ return new Response(JSON.stringify({ error: `Fatia ${resolved.slice.key} não declara sliceSocket` }), {
1885
+ status: 404,
1886
+ headers: { 'Content-Type': 'application/json' }
1887
+ });
1888
+ }
1889
+ const session = await this.requestSession(req);
1890
+ const upgraded = (this.httpServer as any)?.upgrade(req, {
1891
+ data: {
1892
+ id: crypto.randomUUID(),
1893
+ sliceKey: resolved.slice.key,
1894
+ socketDef,
1895
+ session,
1896
+ clientKey: wsClientKey
1897
+ }
1898
+ });
1899
+ if (upgraded) {
1900
+ return undefined as any;
1901
+ }
1902
+
1903
+ this.releaseRealtimeConnection(wsClientKey);
1904
+ return new Response('Falha no upgrade de WebSocket', { status: 400 });
1905
+ }
1906
+
986
1907
  // Arquivos estáticos primeiro: `public/` do app, estritamente contido no diretório.
987
1908
  if (!pathname.startsWith('/_synapse/') && pathname !== '/') {
988
1909
  const publicDir = path.resolve(this.baseDir, 'public');
@@ -1009,6 +1930,18 @@ ${this.renderStylesheets()}
1009
1930
  });
1010
1931
  }
1011
1932
 
1933
+ // 1.5 Rate Limiter for every accepting surface (machine endpoints, RPC,
1934
+ // uploads, webhooks, SSE, images). Server-rendered page loads are covered
1935
+ // separately, right before the render begins. Static files, the dashboard
1936
+ // and the generated client bundles are not part of this budget.
1937
+ if (RATE_LIMITED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
1938
+ const refusal = this.rateLimitRefusal(req, pathname, startedAt);
1939
+
1940
+ if (refusal) {
1941
+ return refusal;
1942
+ }
1943
+ }
1944
+
1012
1945
  // 2. Machine Endpoints for AI
1013
1946
  if (pathname === '/_synapse/api/repo-map') {
1014
1947
  const repoMapPath = path.join(this.baseDir, '.codebase/repo-map.d.ts');
@@ -1036,28 +1969,34 @@ ${this.renderStylesheets()}
1036
1969
  const healthy = dbHealthy && !this.discoveryError;
1037
1970
  const status = healthy ? 'OK' : 'DEGRADED';
1038
1971
  const httpStatus = healthy ? 200 : 503;
1972
+ const session = await this.requestSession(req);
1973
+
1974
+ // This endpoint requires no authentication, so it discloses only the
1975
+ // aggregate verdict: no absolute path, no discovery candidate, no load-error
1976
+ // text and no route inventory. The detailed inventory is authenticated.
1977
+ const payload: Record<string, unknown> = {
1978
+ status,
1979
+ framework: 'SynapseJS',
1980
+ version: SYNAPSE_VERSION,
1981
+ uptime: process.uptime(),
1982
+ database: dbHealthy ? 'connected' : 'disconnected',
1983
+ slicesLoaded: this.slices.size
1984
+ };
1039
1985
 
1040
- this.logRequest(req, pathname, httpStatus, startedAt);
1041
- return Response.json(
1042
- {
1043
- status,
1044
- framework: 'SynapseJS',
1045
- version: '1.2.0',
1046
- uptime: process.uptime(),
1047
- database: dbHealthy ? 'connected' : 'disconnected',
1048
- ...(dbError ? { databaseError: dbError } : {}),
1049
- slicesLoaded: this.slices.size,
1050
- slicesResolutionError: this.discoveryError,
1051
- loadErrors: this.loadErrors,
1052
- slices: Array.from(this.slices.values()).map((s) => ({
1053
- domain: s.domain,
1054
- name: s.name,
1055
- route: s.routePath,
1056
- rpc: s.rpcPath
1057
- }))
1058
- },
1059
- { status: httpStatus }
1060
- );
1986
+ if (session.isAuthenticated) {
1987
+ payload.databaseError = dbError;
1988
+ payload.slicesResolutionError = this.discoveryError;
1989
+ payload.loadErrors = this.loadErrors;
1990
+ payload.slices = Array.from(this.slices.values()).map((s) => ({
1991
+ domain: s.domain,
1992
+ name: s.name,
1993
+ route: s.routePath,
1994
+ rpc: s.rpcPath
1995
+ }));
1996
+ }
1997
+
1998
+ this.logRequest(req, pathname, httpStatus, startedAt, session);
1999
+ return Response.json(payload, { status: httpStatus });
1061
2000
  }
1062
2001
 
1063
2002
  if (pathname === '/_synapse/api/metrics') {
@@ -1068,26 +2007,27 @@ ${this.renderStylesheets()}
1068
2007
  this.logRequest(req, pathname, 200, startedAt);
1069
2008
 
1070
2009
  if (formatParam === 'prometheus' || acceptHeader.includes('text/plain')) {
1071
- const promText =
1072
- [
1073
- '# HELP synapse_uptime_seconds SynapseJS server uptime in seconds',
1074
- '# TYPE synapse_uptime_seconds gauge',
1075
- `synapse_uptime_seconds ${uptimeSeconds}`,
1076
- '# HELP synapse_requests_total Total number of HTTP requests received',
1077
- '# TYPE synapse_requests_total counter',
1078
- `synapse_requests_total ${this.metrics.totalRequests}`,
1079
- '# HELP synapse_rpc_success_total Total successful RPC action calls',
1080
- '# TYPE synapse_rpc_success_total counter',
1081
- `synapse_rpc_success_total ${this.metrics.rpcSuccessCount}`,
1082
- '# HELP synapse_rpc_error_total Total failed RPC action calls',
1083
- '# TYPE synapse_rpc_error_total counter',
1084
- `synapse_rpc_error_total ${this.metrics.rpcErrorCount}`,
1085
- '# HELP synapse_ssr_renders_total Total server-side rendered pages',
1086
- '# TYPE synapse_ssr_renders_total counter',
1087
- `synapse_ssr_renders_total ${this.metrics.ssrRenderCount}`
1088
- ].join('\n') + '\n';
1089
-
1090
- return new Response(promText, {
2010
+ const promText = [
2011
+ '# HELP synapse_uptime_seconds SynapseJS server uptime in seconds',
2012
+ '# TYPE synapse_uptime_seconds gauge',
2013
+ `synapse_uptime_seconds ${uptimeSeconds}`,
2014
+ '# HELP synapse_requests_total Total number of HTTP requests received',
2015
+ '# TYPE synapse_requests_total counter',
2016
+ `synapse_requests_total ${this.metrics.totalRequests}`,
2017
+ '# HELP synapse_rpc_success_total Total successful RPC action calls',
2018
+ '# TYPE synapse_rpc_success_total counter',
2019
+ `synapse_rpc_success_total ${this.metrics.rpcSuccessCount}`,
2020
+ '# HELP synapse_rpc_error_total Total failed RPC action calls',
2021
+ '# TYPE synapse_rpc_error_total counter',
2022
+ `synapse_rpc_error_total ${this.metrics.rpcErrorCount}`,
2023
+ '# HELP synapse_ssr_renders_total Total server-side rendered pages',
2024
+ '# TYPE synapse_ssr_renders_total counter',
2025
+ `synapse_ssr_renders_total ${this.metrics.ssrRenderCount}`
2026
+ ].join('\n');
2027
+
2028
+ const promTextWithNewline = `${promText}\n`;
2029
+
2030
+ return new Response(promTextWithNewline, {
1091
2031
  headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }
1092
2032
  });
1093
2033
  }
@@ -1103,43 +2043,6 @@ ${this.renderStylesheets()}
1103
2043
  });
1104
2044
  }
1105
2045
 
1106
- // 3. Rate Limiter for Sensitive Endpoints (RPC, Uploads, Webhooks)
1107
- if (
1108
- pathname.startsWith('/_synapse/rpc/') ||
1109
- pathname.startsWith('/_synapse/files/') ||
1110
- pathname.startsWith('/_synapse/webhooks/')
1111
- ) {
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';
1117
-
1118
- const rateResult = this.rateLimiter.consume(clientKey);
1119
- if (!rateResult.allowed) {
1120
- const cors = this.corsHeaders(req);
1121
- this.logRequest(req, pathname, 429, startedAt);
1122
- return new Response(
1123
- JSON.stringify({
1124
- ok: false,
1125
- error: 'RATE_LIMIT_EXCEEDED',
1126
- message: 'Muitas requisições. Tente novamente mais tarde.'
1127
- }),
1128
- {
1129
- status: 429,
1130
- headers: {
1131
- 'Content-Type': 'application/json; charset=utf-8',
1132
- 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000)),
1133
- 'X-RateLimit-Limit': String(rateResult.limit),
1134
- 'X-RateLimit-Remaining': String(rateResult.remaining),
1135
- 'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateResult.resetMs) / 1000)),
1136
- ...(cors ?? {})
1137
- }
1138
- }
1139
- );
1140
- }
1141
- }
1142
-
1143
2046
  // 3. RPC Actions Dispatcher (POST /_synapse/rpc/:sliceName)
1144
2047
  if (pathname.startsWith('/_synapse/rpc/')) {
1145
2048
  const cors = this.corsHeaders(req);
@@ -1182,20 +2085,72 @@ ${this.renderStylesheets()}
1182
2085
  );
1183
2086
  }
1184
2087
 
2088
+ const maxRpcBytes = this.config.maxRpcPayloadBytes ?? 5 * 1024 * 1024; // 5 MB
2089
+ const rpcContentLengthHeader = req.headers.get('content-length');
2090
+ const rpcContentLength = rpcContentLengthHeader ? parseInt(rpcContentLengthHeader, 10) : NaN;
2091
+ if (Number.isFinite(rpcContentLength) && rpcContentLength > maxRpcBytes) {
2092
+ this.logRequest(req, pathname, 413, startedAt, undefined, 'rpc_error');
2093
+ return Response.json(
2094
+ {
2095
+ ok: false,
2096
+ error: 'PAYLOAD_TOO_LARGE',
2097
+ message: `Payload RPC excede o limite máximo permitido de ${maxRpcBytes} bytes.`
2098
+ },
2099
+ { status: 413, headers: cors ?? undefined }
2100
+ );
2101
+ }
2102
+
2103
+ const bodyResult = await readBodyWithinLimit(req.body, maxRpcBytes);
2104
+ if (!bodyResult.ok) {
2105
+ this.logRequest(req, pathname, 413, startedAt, undefined, 'rpc_error');
2106
+ return Response.json(
2107
+ {
2108
+ ok: false,
2109
+ error: 'PAYLOAD_TOO_LARGE',
2110
+ message: `Payload RPC excede o limite máximo permitido de ${maxRpcBytes} bytes.`
2111
+ },
2112
+ { status: 413, headers: cors ?? undefined }
2113
+ );
2114
+ }
2115
+
2116
+ let body: unknown;
2117
+ try {
2118
+ const bodyText = new TextDecoder().decode(bodyResult.value);
2119
+ body = bodyText.trim().length > 0 ? JSON.parse(bodyText) : {};
2120
+ } catch {
2121
+ this.logRequest(req, pathname, 400, startedAt, undefined, 'rpc_error');
2122
+ return Response.json(
2123
+ {
2124
+ ok: false,
2125
+ error: 'INVALID_JSON_PAYLOAD',
2126
+ message: 'Payload da requisição não é um JSON válido.'
2127
+ },
2128
+ { status: 400, headers: cors ?? undefined }
2129
+ );
2130
+ }
2131
+
1185
2132
  try {
1186
- const body = await req.json();
1187
- const session = this.sessionFrom(req);
1188
- const actionCtx = this.createActionContext(slice.name, session);
2133
+ const session = await this.requestSession(req);
2134
+ const actionCtx = this.createActionContext(slice.key, session);
1189
2135
 
1190
2136
  const result = await slice.actionFn(body, actionCtx, session, actionCtx);
1191
2137
  const status = result.ok ? 200 : httpStatusForError(result.error);
1192
2138
  const metricType = result.ok ? 'rpc_success' : 'rpc_error';
1193
2139
  this.logRequest(req, pathname, status, startedAt, session, metricType);
1194
2140
 
1195
- return Response.json(result, { status, headers: cors ?? undefined });
1196
- } catch (err: any) {
2141
+ const headers = new Headers(cors ?? {});
2142
+ headers.set('Content-Type', 'application/json');
2143
+
2144
+ if (actionCtx._pendingCookies && actionCtx._pendingCookies.length > 0) {
2145
+ for (const cookie of actionCtx._pendingCookies) {
2146
+ headers.append('Set-Cookie', serializeCookie(cookie.name, cookie.value, cookie.options));
2147
+ }
2148
+ }
2149
+
2150
+ return new Response(JSON.stringify(result), { status, headers });
2151
+ } catch (err: unknown) {
1197
2152
  this.logRequest(req, pathname, 500, startedAt, undefined, 'rpc_error');
1198
- return Response.json({ ok: false, error: `Falha interna no RPC: ${err.message}` }, { status: 500 });
2153
+ return this.internalFailure(err, pathname, cors);
1199
2154
  }
1200
2155
  }
1201
2156
 
@@ -1231,8 +2186,36 @@ ${this.renderStylesheets()}
1231
2186
  );
1232
2187
  }
1233
2188
 
2189
+ const maxWebhookBytes = this.config.maxWebhookPayloadBytes ?? 10 * 1024 * 1024; // 10 MB
2190
+ const webhookContentLengthHeader = req.headers.get('content-length');
2191
+ const webhookContentLength = webhookContentLengthHeader ? parseInt(webhookContentLengthHeader, 10) : NaN;
2192
+ if (Number.isFinite(webhookContentLength) && webhookContentLength > maxWebhookBytes) {
2193
+ this.logRequest(req, pathname, 413, startedAt);
2194
+ return Response.json(
2195
+ {
2196
+ ok: false,
2197
+ error: 'PAYLOAD_TOO_LARGE',
2198
+ message: `Payload Webhook excede o limite máximo permitido de ${maxWebhookBytes} bytes.`
2199
+ },
2200
+ { status: 413, headers: cors ?? undefined }
2201
+ );
2202
+ }
2203
+
2204
+ const bodyResult = await readBodyWithinLimit(req.body, maxWebhookBytes);
2205
+ if (!bodyResult.ok) {
2206
+ this.logRequest(req, pathname, 413, startedAt);
2207
+ return Response.json(
2208
+ {
2209
+ ok: false,
2210
+ error: 'PAYLOAD_TOO_LARGE',
2211
+ message: `Payload Webhook excede o limite máximo permitido de ${maxWebhookBytes} bytes.`
2212
+ },
2213
+ { status: 413, headers: cors ?? undefined }
2214
+ );
2215
+ }
2216
+
1234
2217
  try {
1235
- const rawBody = new Uint8Array(await req.arrayBuffer());
2218
+ const rawBody = bodyResult.value;
1236
2219
  const bodyText = new TextDecoder().decode(rawBody);
1237
2220
  let json: unknown = null;
1238
2221
  try {
@@ -1241,8 +2224,8 @@ ${this.renderStylesheets()}
1241
2224
  json = null;
1242
2225
  }
1243
2226
 
1244
- const session = this.sessionFrom(req);
1245
- const actionCtx = this.createActionContext(slice.name, session);
2227
+ const session = await this.requestSession(req);
2228
+ const actionCtx = this.createActionContext(slice.key, session);
1246
2229
 
1247
2230
  const result = await slice.webhookFn(
1248
2231
  {
@@ -1262,12 +2245,8 @@ ${this.renderStylesheets()}
1262
2245
  }
1263
2246
  return Response.json(result, { status, headers: cors ?? undefined });
1264
2247
  } catch (err: unknown) {
1265
- const message = err instanceof Error ? err.message : String(err);
1266
2248
  this.logRequest(req, pathname, 500, startedAt);
1267
- return Response.json(
1268
- { ok: false, error: `Falha no processamento de webhook: ${message}` },
1269
- { status: 500 }
1270
- );
2249
+ return this.internalFailure(err, pathname, cors);
1271
2250
  }
1272
2251
  }
1273
2252
 
@@ -1287,16 +2266,96 @@ ${this.renderStylesheets()}
1287
2266
  const topic = decodeURIComponent(rawTopic);
1288
2267
 
1289
2268
  if (!topic) {
1290
- return Response.json({ ok: false, error: 'Tópico de subscrição inválido.' }, { status: 400 });
2269
+ return this.realtimeRefusal('TOPIC_NOT_FOUND', 404, 'Tópico de subscrição inválido.', cors);
2270
+ }
2271
+
2272
+ const session = await this.requestSession(req);
2273
+
2274
+ // Access is declared, not inferred: a name is not a permission.
2275
+ const declaration = declaredTopic(topic, session.tenantId);
2276
+
2277
+ if (!session.isAuthenticated && !declaration?.public) {
2278
+ return this.realtimeRefusal('UNAUTHENTICATED', 401, 'Subscrição requer uma sessão autenticada.', cors);
2279
+ }
2280
+
2281
+ if (!declaration) {
2282
+ return this.realtimeRefusal('TOPIC_NOT_FOUND', 404, 'Tópico não declarado.', cors);
1291
2283
  }
1292
2284
 
2285
+ if (declaration.readRoles?.length && !hasAnyRole(session, declaration.readRoles)) {
2286
+ return this.realtimeRefusal('TOPIC_FORBIDDEN', 403, 'Sessão sem papel para este tópico.', cors);
2287
+ }
2288
+
2289
+ const bounds = this.realtimeBounds();
2290
+
2291
+ if (this.openStreams.size >= bounds.maxSubscriptions) {
2292
+ return this.realtimeRefusal(
2293
+ 'REALTIME_LIMIT_EXCEEDED',
2294
+ 429,
2295
+ 'Limite de subscrições simultâneas atingido.',
2296
+ cors
2297
+ );
2298
+ }
2299
+
2300
+ const effectiveTopic = scopedTopic(topic, session.tenantId);
1293
2301
  const eventHub = getEventHub();
1294
- const session = this.sessionFrom(req);
2302
+ const isNewTopic = eventHub.listenerCount(effectiveTopic) === 0;
2303
+
2304
+ if (isNewTopic && eventHub.activeTopics().length >= bounds.maxTopics) {
2305
+ return this.realtimeRefusal(
2306
+ 'REALTIME_LIMIT_EXCEEDED',
2307
+ 429,
2308
+ 'Limite de tópicos registrados atingido.',
2309
+ cors
2310
+ );
2311
+ }
2312
+
2313
+ const clientKey = this.clientIdentity(req);
2314
+ const openForClient = this.realtimeConnections.get(clientKey) ?? 0;
2315
+
2316
+ if (openForClient >= bounds.maxConnectionsPerClient) {
2317
+ return this.realtimeRefusal(
2318
+ 'REALTIME_LIMIT_EXCEEDED',
2319
+ 429,
2320
+ 'Limite de conexões simultâneas por cliente atingido.',
2321
+ cors
2322
+ );
2323
+ }
2324
+
2325
+ this.realtimeConnections.set(clientKey, openForClient + 1);
2326
+
1295
2327
  let unsubscribe: (() => void) | null = null;
1296
2328
  let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
1297
2329
 
1298
- const stream = new ReadableStream({
2330
+ const cleanup = () => {
2331
+ if (keepAliveTimer) {
2332
+ clearInterval(keepAliveTimer);
2333
+ keepAliveTimer = null;
2334
+ }
2335
+ if (unsubscribe) {
2336
+ unsubscribe();
2337
+ unsubscribe = null;
2338
+ }
2339
+ const open = this.realtimeConnections.get(clientKey) ?? 0;
2340
+ if (open <= 1) {
2341
+ this.realtimeConnections.delete(clientKey);
2342
+ } else {
2343
+ this.realtimeConnections.set(clientKey, open - 1);
2344
+ }
2345
+ };
2346
+
2347
+ req.signal.addEventListener('abort', cleanup);
2348
+
2349
+ let streamController: ReadableStreamDefaultController<Uint8Array> | null = null;
2350
+ const server = this;
2351
+ const stream = new ReadableStream<Uint8Array>({
1299
2352
  start(controller) {
2353
+ streamController = controller;
2354
+ // An open stream is not drainable work: it never completes on its own.
2355
+ // It is tracked separately and closed deliberately when shutdown begins.
2356
+ server.openStreams.add(controller);
2357
+ releaseInFlight();
2358
+
1300
2359
  const encoder = new TextEncoder();
1301
2360
  controller.enqueue(encoder.encode(': connected\n\n'));
1302
2361
 
@@ -1304,22 +2363,24 @@ ${this.renderStylesheets()}
1304
2363
  try {
1305
2364
  controller.enqueue(encoder.encode(': keep-alive\n\n'));
1306
2365
  } catch {
1307
- if (keepAliveTimer) clearInterval(keepAliveTimer);
2366
+ cleanup();
1308
2367
  }
1309
2368
  }, 15000);
1310
2369
 
1311
- unsubscribe = eventHub.subscribe(topic, (data) => {
2370
+ unsubscribe = eventHub.subscribe(effectiveTopic, (data) => {
1312
2371
  try {
1313
2372
  const eventData = JSON.stringify(data);
1314
2373
  controller.enqueue(encoder.encode(`event: message\ndata: ${eventData}\n\n`));
1315
2374
  } catch (err) {
1316
- console.error(`[SSE] Erro ao serializar evento para tópico "${topic}":`, err);
2375
+ console.error(`[SSE] Erro ao serializar evento para tópico "${effectiveTopic}":`, err);
1317
2376
  }
1318
2377
  });
1319
2378
  },
1320
2379
  cancel() {
1321
- if (keepAliveTimer) clearInterval(keepAliveTimer);
1322
- if (unsubscribe) unsubscribe();
2380
+ if (streamController) {
2381
+ server.openStreams.delete(streamController);
2382
+ }
2383
+ cleanup();
1323
2384
  }
1324
2385
  });
1325
2386
 
@@ -1357,7 +2418,7 @@ ${this.renderStylesheets()}
1357
2418
  const outcome = await saveUpload(req, {
1358
2419
  baseDir: this.baseDir,
1359
2420
  domain: resolvedUpload.slice.domain,
1360
- session: this.sessionFrom(req)
2421
+ session: await this.requestSession(req)
1361
2422
  });
1362
2423
 
1363
2424
  this.logRequest(req, pathname, outcome.status, startedAt);
@@ -1372,6 +2433,11 @@ ${this.renderStylesheets()}
1372
2433
  return new Response(null, { status: cors ? 204 : 404, headers: cors ?? undefined });
1373
2434
  }
1374
2435
 
2436
+ const session = await this.requestSession(req);
2437
+ if (!session.isAuthenticated) {
2438
+ return Response.json({ ok: false, error: 'UNAUTHORIZED' }, { status: 401, headers: cors ?? undefined });
2439
+ }
2440
+
1375
2441
  const fileUrl = url.searchParams.get('url');
1376
2442
  const widthParam = url.searchParams.get('w');
1377
2443
  const heightParam = url.searchParams.get('h');
@@ -1387,17 +2453,50 @@ ${this.renderStylesheets()}
1387
2453
  let mimeType = 'image/jpeg';
1388
2454
 
1389
2455
  if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
1390
- const res = await fetch(fileUrl);
1391
- if (!res.ok) {
1392
- return Response.json({ ok: false, error: 'FETCH_IMAGE_FAILED' }, { status: 502 });
2456
+ const allowedDomains =
2457
+ this.config.imageOptimizer?.allowedDomains ||
2458
+ (process.env.SYNAPSE_ALLOWED_IMAGE_DOMAINS
2459
+ ? process.env.SYNAPSE_ALLOWED_IMAGE_DOMAINS.split(',').map((s) => s.trim())
2460
+ : undefined);
2461
+
2462
+ const validation = await validateExternalUrl(fileUrl, { allowedDomains });
2463
+ if (!validation.ok) {
2464
+ return Response.json(
2465
+ { ok: false, error: validation.error || 'FORBIDDEN_TARGET_URL' },
2466
+ { status: 403, headers: cors ?? undefined }
2467
+ );
2468
+ }
2469
+
2470
+ const fetched = await fetchPinnedExternal(fileUrl, validation.resolvedIp, {
2471
+ maxBytes: this.config.imageOptimizer?.maxResponseBytes ?? 10 * 1024 * 1024,
2472
+ timeoutMs: this.config.imageOptimizer?.timeoutMs ?? 10_000
2473
+ });
2474
+
2475
+ if (!fetched.ok || !fetched.body) {
2476
+ return Response.json(
2477
+ { ok: false, error: fetched.code || 'FETCH_IMAGE_FAILED' },
2478
+ { status: fetched.status ?? 502, headers: cors ?? undefined }
2479
+ );
1393
2480
  }
1394
- buffer = new Uint8Array(await res.arrayBuffer());
1395
- mimeType = res.headers.get('content-type') || mimeType;
2481
+
2482
+ buffer = fetched.body;
2483
+ mimeType = fetched.contentType || mimeType;
1396
2484
  } else {
2485
+ const publicDir = path.resolve(this.baseDir, 'public');
1397
2486
  const cleanRel = fileUrl.replace(/^\/+/, '');
1398
- const candidate = path.resolve(this.baseDir, cleanRel);
2487
+ const candidate = path.resolve(publicDir, cleanRel);
2488
+
2489
+ if (!candidate.startsWith(publicDir + path.sep) && candidate !== publicDir) {
2490
+ return Response.json(
2491
+ { ok: false, error: 'FORBIDDEN_FILE_PATH' },
2492
+ { status: 403, headers: cors ?? undefined }
2493
+ );
2494
+ }
1399
2495
  if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
1400
- return Response.json({ ok: false, error: 'IMAGE_NOT_FOUND' }, { status: 404 });
2496
+ return Response.json(
2497
+ { ok: false, error: 'IMAGE_NOT_FOUND' },
2498
+ { status: 404, headers: cors ?? undefined }
2499
+ );
1401
2500
  }
1402
2501
  buffer = new Uint8Array(await Bun.file(candidate).arrayBuffer());
1403
2502
  }
@@ -1419,8 +2518,7 @@ ${this.renderStylesheets()}
1419
2518
  }
1420
2519
  });
1421
2520
  } catch (err: unknown) {
1422
- const msg = err instanceof Error ? err.message : String(err);
1423
- return Response.json({ ok: false, error: msg }, { status: 500 });
2521
+ return this.internalFailure(err, pathname, cors);
1424
2522
  }
1425
2523
  }
1426
2524
 
@@ -1436,7 +2534,7 @@ ${this.renderStylesheets()}
1436
2534
  const bundleFilename = path.basename(pathname);
1437
2535
  const bundleFile = path.join(this.baseDir, '.synapse/client', bundleFilename);
1438
2536
  if (!fs.existsSync(bundleFile) && bundleFilename === VENDOR_BUNDLE_NAME) {
1439
- await buildVendorBundle(this.baseDir);
2537
+ await buildVendorBundle(this.baseDir, this.config.compiler?.vendorPackages);
1440
2538
  }
1441
2539
  if (!fs.existsSync(bundleFile)) {
1442
2540
  this.logRequest(req, pathname, 404, startedAt);
@@ -1475,102 +2573,130 @@ ${this.renderStylesheets()}
1475
2573
 
1476
2574
  if (resolvedSlice) {
1477
2575
  const slice = resolvedSlice;
1478
- 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
- };
1496
2576
 
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}`);
1504
- }
2577
+ // A rendered page load is an accepting surface too, and it is the most
2578
+ // expensive one: refuse before any loader or action runs.
2579
+ const pageRefusal = this.rateLimitRefusal(req, pathname, startedAt);
2580
+
2581
+ if (pageRefusal) {
2582
+ return pageRefusal;
1505
2583
  }
1506
2584
 
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);
2585
+ const session = await this.requestSession(req);
2586
+ const cacheConfig = slice.cacheConfig;
2587
+ const scopePrefix = `${session.tenantId || 'global'}:${session.userId !== 'anon' ? session.userId : 'public'}`;
2588
+ const normalizedKey = cacheConfig
2589
+ ? normalizeCacheKey(url, cacheConfig.allowedParams)
2590
+ : `${pathname}${url.search}`;
2591
+ const cacheKey = `${scopePrefix}:${normalizedKey}`;
2592
+ const now = Date.now();
2593
+
2594
+ if (cacheConfig && req.method === 'GET') {
2595
+ const cached = this.ssrCache.get(cacheKey);
2596
+ if (cached) {
2597
+ if (now < cached.expiresAt) {
2598
+ this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
2599
+ return new Response(cached.html, {
2600
+ headers: {
2601
+ 'Content-Type': 'text/html; charset=utf-8',
2602
+ 'X-Synapse-Cache': 'HIT',
2603
+ 'Cache-Control': `public, max-age=${cached.ttlSeconds}, stale-while-revalidate=${cached.swrSeconds}`
2604
+ }
2605
+ });
2606
+ }
2607
+ if (now < cached.staleUntil) {
2608
+ this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
2609
+ // Revalidação em background (ISR / Stale-While-Revalidate) - Thundering Herd Guard
2610
+ if (!this.revalidatingKeys.has(cacheKey)) {
2611
+ this.revalidatingKeys.add(cacheKey);
2612
+ (async () => {
2613
+ try {
2614
+ const fresh = await this.renderSliceHtml(slice, req, url, matchedLocale);
2615
+ if (!fresh.hasError) {
2616
+ const ttl = cacheConfig.ttlSeconds;
2617
+ const swr = cacheConfig.staleWhileRevalidateSeconds ?? 0;
2618
+ this.ssrCache.set(cacheKey, {
2619
+ html: fresh.html,
2620
+ expiresAt: Date.now() + ttl * 1000,
2621
+ staleUntil: Date.now() + (ttl + swr) * 1000,
2622
+ ttlSeconds: ttl,
2623
+ swrSeconds: swr,
2624
+ tags: cacheConfig.tags || []
2625
+ });
2626
+ }
2627
+ } catch (err) {
2628
+ console.error(`[SynapseServer] Revalidação ISR em background de ${slice.key} falhou:`, err);
2629
+ } finally {
2630
+ this.revalidatingKeys.delete(cacheKey);
2631
+ }
2632
+ })();
2633
+ }
2634
+
2635
+ return new Response(cached.html, {
2636
+ headers: {
2637
+ 'Content-Type': 'text/html; charset=utf-8',
2638
+ 'X-Synapse-Cache': 'STALE',
2639
+ 'Cache-Control': `public, max-age=${cached.ttlSeconds}, stale-while-revalidate=${cached.swrSeconds}`
2640
+ }
2641
+ });
2642
+ }
1513
2643
  }
1514
2644
  }
1515
2645
 
1516
- const clientUrl = loaderError ? null : await this.ensureClientBundle(slice);
2646
+ const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale);
1517
2647
 
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
- : {})
2648
+ if (hasError) {
2649
+ this.logRequest(req, pathname, 500, startedAt, session, 'ssr');
2650
+ return new Response(html, {
2651
+ status: 500,
2652
+ headers: {
2653
+ 'Content-Type': 'text/html; charset=utf-8',
2654
+ 'X-Robots-Tag': 'noindex, nofollow'
2655
+ }
2656
+ });
2657
+ }
2658
+
2659
+ const headers: Record<string, string> = {
2660
+ 'Content-Type': 'text/html; charset=utf-8'
1526
2661
  };
1527
2662
 
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>';
2663
+ if (cacheConfig && req.method === 'GET') {
2664
+ const ttl = cacheConfig.ttlSeconds;
2665
+ const swr = cacheConfig.staleWhileRevalidateSeconds ?? 0;
2666
+ this.ssrCache.set(cacheKey, {
2667
+ html,
2668
+ expiresAt: now + ttl * 1000,
2669
+ staleUntil: now + (ttl + swr) * 1000,
2670
+ ttlSeconds: ttl,
2671
+ swrSeconds: swr,
2672
+ tags: cacheConfig.tags || []
2673
+ });
2674
+ headers['X-Synapse-Cache'] = 'MISS';
2675
+ headers['Cache-Control'] = `public, max-age=${ttl}, stale-while-revalidate=${swr}`;
1549
2676
  }
1550
2677
 
1551
2678
  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
- });
2679
+ return new Response(html, { headers });
1567
2680
  }
1568
2681
 
1569
2682
  this.logRequest(req, pathname, 404, startedAt);
1570
2683
  return new Response('404 Not Found in SynapseJS Router', { status: 404 });
1571
2684
  };
1572
2685
 
1573
- let response = await handleRequest();
2686
+ let response: Response | undefined;
2687
+
2688
+ try {
2689
+ response = await Promise.race([handleRequest(), this.drainSignal]);
2690
+ } finally {
2691
+ releaseInFlight();
2692
+ }
2693
+
2694
+ // A successful WebSocket upgrade returned nothing: the connection is no
2695
+ // longer an HTTP response, so neither plugins nor headers apply to it.
2696
+ if (!(response instanceof Response)) {
2697
+ return response as unknown as Response;
2698
+ }
2699
+
1574
2700
  if (this.config.plugins) {
1575
2701
  for (const plugin of this.config.plugins) {
1576
2702
  if (plugin.onResponse) {
@@ -1578,7 +2704,10 @@ ${this.renderStylesheets()}
1578
2704
  }
1579
2705
  }
1580
2706
  }
1581
- return response;
2707
+
2708
+ // The baseline security headers are applied here, so every response — routed,
2709
+ // unrouted, static or an error page — carries the same set.
2710
+ return this.withBaselineHeaders(response, req);
1582
2711
  }
1583
2712
  });
1584
2713