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