@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.
- package/README.md +108 -10
- package/bin/synapse.ts +263 -218
- package/package.json +1 -1
- package/src/client/components.tsx +26 -9
- package/src/client/outlet.tsx +40 -0
- package/src/client/sub-slice.tsx +45 -0
- package/src/client/use-websocket.ts +146 -0
- package/src/client.ts +12 -0
- package/src/compiler/client-bundler.ts +17 -7
- package/src/compiler/context-bench.ts +7 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +144 -11
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +8 -3
- package/src/compiler/scaffolder.ts +174 -3
- package/src/compiler/schema-dag.ts +6 -3
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +67 -6
- package/src/compiler/slice-templates.ts +10 -0
- package/src/compiler/standalone-builder.ts +16 -2
- package/src/core/action-context.ts +101 -8
- package/src/core/cache.ts +139 -0
- package/src/core/config.ts +124 -10
- package/src/core/database-client.ts +68 -6
- package/src/core/index.ts +5 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +6 -0
- package/src/core/postgres-queue.ts +45 -7
- package/src/core/query-builder.ts +75 -60
- package/src/core/session-context.ts +1 -1
- package/src/core/session-token.ts +115 -8
- package/src/core/sqlite-client.ts +26 -1
- package/src/core/storage.ts +61 -24
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/core/websocket.ts +30 -0
- package/src/index.ts +10 -2
- package/src/mcp/server.ts +78 -173
- package/src/mcp/tools.ts +238 -0
- package/src/project-status.ts +330 -0
- package/src/runtime/discovery-rules.ts +20 -1
- package/src/runtime/event-hub.ts +6 -1
- package/src/runtime/network-guard.ts +182 -0
- package/src/runtime/queue-engine.ts +102 -21
- package/src/runtime/rate-limiter.ts +24 -0
- package/src/runtime/server.ts +1380 -251
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +11 -0
- package/templates/starter/.jev.json +1 -1
- package/templates/starter/tsconfig.json +1 -0
package/src/runtime/server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
279
|
-
if (this.
|
|
280
|
-
this.
|
|
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(
|
|
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(
|
|
324
|
-
|
|
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 (
|
|
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
|
-
|
|
432
|
-
|
|
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(
|
|
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:
|
|
989
|
+
tenantId: verified.value.tenantId,
|
|
588
990
|
roles: verified.value.roles,
|
|
589
991
|
token
|
|
590
992
|
})
|
|
591
|
-
: AnonymousSession(
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
|
1187
|
-
const
|
|
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
|
-
|
|
1196
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
1245
|
-
const actionCtx = this.createActionContext(slice.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2366
|
+
cleanup();
|
|
1308
2367
|
}
|
|
1309
2368
|
}, 15000);
|
|
1310
2369
|
|
|
1311
|
-
unsubscribe = eventHub.subscribe(
|
|
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 "${
|
|
2375
|
+
console.error(`[SSE] Erro ao serializar evento para tópico "${effectiveTopic}":`, err);
|
|
1317
2376
|
}
|
|
1318
2377
|
});
|
|
1319
2378
|
},
|
|
1320
2379
|
cancel() {
|
|
1321
|
-
if (
|
|
1322
|
-
|
|
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.
|
|
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
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1395
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
|
2646
|
+
const { html, hasError } = await this.renderSliceHtml(slice, req, url, matchedLocale);
|
|
1517
2647
|
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|