@ismaelsoilet/synapsejs 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/bin/synapse.ts +919 -0
  4. package/package.json +70 -0
  5. package/src/client/components.tsx +508 -0
  6. package/src/client/context.tsx +62 -0
  7. package/src/client/hooks.ts +152 -0
  8. package/src/client/i18n.ts +40 -0
  9. package/src/client.ts +66 -0
  10. package/src/compiler/agent-diagnostic-json.ts +113 -0
  11. package/src/compiler/ast-daemon-compressor.ts +190 -0
  12. package/src/compiler/client-bundler.ts +356 -0
  13. package/src/compiler/context-bench.ts +191 -0
  14. package/src/compiler/contract.ts +260 -0
  15. package/src/compiler/db-schema-generator.ts +306 -0
  16. package/src/compiler/fields-parser.ts +267 -0
  17. package/src/compiler/impact-analyzer.ts +279 -0
  18. package/src/compiler/migration-runner.ts +555 -0
  19. package/src/compiler/oracle-runner.ts +293 -0
  20. package/src/compiler/scaffolder.ts +528 -0
  21. package/src/compiler/schema-dag.ts +137 -0
  22. package/src/compiler/schema-drift.ts +184 -0
  23. package/src/compiler/slice-discovery.ts +154 -0
  24. package/src/compiler/slice-splitter.ts +838 -0
  25. package/src/compiler/slice-templates.ts +1527 -0
  26. package/src/compiler/standalone-builder.ts +117 -0
  27. package/src/core/action-context.ts +152 -0
  28. package/src/core/config.ts +77 -0
  29. package/src/core/database-client.ts +246 -0
  30. package/src/core/database-factory.ts +46 -0
  31. package/src/core/image-optimizer.ts +83 -0
  32. package/src/core/index.ts +14 -0
  33. package/src/core/jobs.ts +53 -0
  34. package/src/core/machine-types.ts +75 -0
  35. package/src/core/oracle.ts +20 -0
  36. package/src/core/postgres-client.ts +164 -0
  37. package/src/core/postgres-queue.ts +288 -0
  38. package/src/core/query-builder.ts +343 -0
  39. package/src/core/rpc-client.ts +70 -0
  40. package/src/core/session-context.ts +87 -0
  41. package/src/core/session-cookie.ts +81 -0
  42. package/src/core/session-token.ts +170 -0
  43. package/src/core/sqlite-client.ts +201 -0
  44. package/src/core/storage.ts +317 -0
  45. package/src/index.ts +133 -0
  46. package/src/mcp/server.ts +553 -0
  47. package/src/runtime/client-entry.ts +65 -0
  48. package/src/runtime/discovery-rules.ts +63 -0
  49. package/src/runtime/event-hub.ts +97 -0
  50. package/src/runtime/postgres-event-hub.ts +148 -0
  51. package/src/runtime/queue-engine.ts +255 -0
  52. package/src/runtime/rate-limiter.ts +152 -0
  53. package/src/runtime/server.ts +1550 -0
  54. package/src/runtime/uploads.ts +192 -0
  55. package/templates/starter/.dockerignore +7 -0
  56. package/templates/starter/Dockerfile +40 -0
  57. package/templates/starter/README.md +40 -0
  58. package/templates/starter/gitignore +6 -0
  59. package/templates/starter/package.json +34 -0
  60. package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
  61. package/templates/starter/tsconfig.json +21 -0
@@ -0,0 +1,1550 @@
1
+ /**
2
+ * SynapseJS - Zero-Wiring HTTP Server & Dynamic Slice Dispatcher
3
+ *
4
+ * Leverages native Bun.serve to provide:
5
+ * 1. Automatic file-system slice discovery (Zero-Wiring Routing)
6
+ * 2. SSR HTML Shell with client-side RPC form wiring & Tailwind styling
7
+ * 3. RPC Endpoint Dispatcher (/_synapse/rpc/:sliceName) executing server actions
8
+ * 4. Machine Endpoints for AI agents (/_synapse/api/repo-map, health)
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import React from 'react';
14
+ import { renderToString } from 'react-dom/server';
15
+ import { SynapseProvider } from '../client/context';
16
+ import {
17
+ buildClientBundle,
18
+ buildVendorBundle,
19
+ type ClientManifest,
20
+ freshBundleFrom,
21
+ readClientManifest,
22
+ VENDOR_BUNDLE_NAME
23
+ } from '../compiler/client-bundler';
24
+ import { runSliceMigrations } from '../compiler/migration-runner';
25
+ import { findSliceFiles, resolveSlicesDir } from '../compiler/slice-discovery';
26
+ import {
27
+ type ActionContext,
28
+ AnonymousSession,
29
+ createActionContext,
30
+ createDefaultLogger,
31
+ createSession,
32
+ type DatabaseClient,
33
+ getDatabase,
34
+ loadSynapseConfig,
35
+ optimizeImage,
36
+ resetDatabaseInstance,
37
+ type SessionContext,
38
+ type SynapseConfig
39
+ } from '../core/index';
40
+ import { ROLES_COOKIE, SESSION_COOKIE } from '../core/session-cookie';
41
+ import { verifySessionToken } from '../core/session-token';
42
+ import { serializeClientProps } from './client-entry';
43
+ import { isAction, isComponent, isJob, isLoader, isMeta, isWebhook } from './discovery-rules';
44
+ import { getEventHub } from './event-hub';
45
+ import { QueueEngine } from './queue-engine';
46
+ import { TokenBucketRateLimiter } from './rate-limiter';
47
+ import { saveUpload } from './uploads';
48
+
49
+ export interface SliceMetadata {
50
+ title?: string;
51
+ description?: string;
52
+ keywords?: string[];
53
+ ogImage?: string;
54
+ canonical?: string;
55
+ noIndex?: boolean;
56
+ extraTags?: Array<{ name?: string; property?: string; content: string }>;
57
+ }
58
+
59
+ export interface WebhookEvent<TJson = unknown> {
60
+ readonly rawBody: Uint8Array;
61
+ readonly bodyText: string;
62
+ readonly json: TJson;
63
+ readonly headers: Headers;
64
+ }
65
+
66
+ export interface DiscoveredSlice {
67
+ domain: string;
68
+ name: string;
69
+ /** `<domain>/<name>`: what identifies a slice unambiguously. */
70
+ key: string;
71
+ routePath: string;
72
+ rpcPath: string;
73
+ webhookPath: string;
74
+ filePath: string;
75
+ actionFn?: (payload: unknown, dbOrCtx?: any, session?: SessionContext, extraCtx?: ActionContext) => Promise<any>;
76
+ componentFn?: React.ComponentType<any>;
77
+ /** Export name of the component, so the generated client entry can import it. */
78
+ componentExport?: string;
79
+ /** Optional server-side data for the component: `export function <Name>Loader(context)`. */
80
+ loaderFn?: (context: SliceLoaderContext) => Promise<Record<string, unknown>>;
81
+ /** Optional metadata generator for SSR `<head>`: `export function <Name>Meta(props, context)` or `sliceMeta`. */
82
+ metaFn?: (props: Record<string, unknown>, context: SliceLoaderContext) => SliceMetadata | Promise<SliceMetadata>;
83
+ /** Optional Webhook handler receiving rawBody and ActionContext */
84
+ webhookFn?: (event: WebhookEvent, ctx: ActionContext) => Promise<any>;
85
+ }
86
+
87
+ /**
88
+ * What a slice loader receives. The loader runs on the server, on every SSR
89
+ * request: it is how a component gets real data instead of hardcoded props.
90
+ */
91
+ export const I18N_LOCALE_REGEX = /^\/([a-z]{2}(?:-[A-Z]{2})?)(?:\/|$)/;
92
+
93
+ export interface SliceLoaderContext {
94
+ url: string;
95
+ params: Record<string, string>;
96
+ db: DatabaseClient;
97
+ session: SessionContext;
98
+ ctx?: ActionContext;
99
+ services?: Record<string, unknown>;
100
+ locale?: string;
101
+ }
102
+
103
+ export interface SliceLoadError {
104
+ file: string;
105
+ message: string;
106
+ }
107
+
108
+ /**
109
+ * Domain error codes are the contract; the HTTP status is a transport detail
110
+ * derived from them. Before this, every failure was 400, so a client could not
111
+ * tell an expired session (401) from a missing row (404) or a duplicate (409).
112
+ * Unknown codes keep 400: the domain, not the transport, decides.
113
+ */
114
+ const ERROR_STATUS: Array<{ pattern: RegExp; status: number }> = [
115
+ { pattern: /^UNAUTHORIZED$/, status: 401 },
116
+ { pattern: /^FORBIDDEN$/, status: 403 },
117
+ { pattern: /_NOT_FOUND$/, status: 404 },
118
+ { pattern: /^(DUPLICATE_|ALREADY_|CONFLICT)/, status: 409 },
119
+ { pattern: /^(INVALID_|MALFORMED)/, status: 422 },
120
+ { pattern: /^(NO_DATABASE|PERSISTENCE_FAILED)/, status: 500 },
121
+ { pattern: /_FAILED$/, status: 500 }
122
+ ];
123
+
124
+ export const TURBO_ROUTER_SCRIPT = `(function() {
125
+ if (window.__synapseNavInstalled) return;
126
+ window.__synapseNavInstalled = true;
127
+
128
+ async function navigate(href, push) {
129
+ try {
130
+ const res = await fetch(href, { headers: { 'X-Synapse-Morph': 'true' } });
131
+ if (!res.ok) { window.location.href = href; return; }
132
+ const html = await res.text();
133
+ const doc = new DOMParser().parseFromString(html, 'text/html');
134
+ const newRoot = doc.querySelector('#synapse-root');
135
+ const curRoot = document.querySelector('#synapse-root');
136
+ if (newRoot && curRoot) {
137
+ curRoot.innerHTML = newRoot.innerHTML;
138
+ document.title = doc.title;
139
+ var newPropsScript = doc.querySelector('script[data-synapse-props]');
140
+ if (newPropsScript) {
141
+ try {
142
+ eval(newPropsScript.textContent);
143
+ } catch (_) {}
144
+ }
145
+ if (push) history.pushState({}, '', href);
146
+ window.dispatchEvent(new CustomEvent('synapse:morphed', { detail: { url: href } }));
147
+ var newClientScript = doc.querySelector('script[data-synapse-client]');
148
+ if (newClientScript && newClientScript.src) {
149
+ import(newClientScript.src).then(function(mod) {
150
+ if (mod && typeof mod.hydrate === 'function') {
151
+ mod.hydrate();
152
+ }
153
+ }).catch(function(err) {
154
+ console.error('[Synapse Router] Hydration error:', err);
155
+ });
156
+ }
157
+ } else {
158
+ window.location.href = href;
159
+ }
160
+ } catch {
161
+ window.location.href = href;
162
+ }
163
+ }
164
+
165
+ document.addEventListener('click', function(e) {
166
+ var a = e.target.closest('a');
167
+ if (!a || !a.href || a.target || a.hasAttribute('download')) return;
168
+ var url = new URL(a.href, location.origin);
169
+ if (url.origin !== location.origin) return;
170
+ if (url.pathname.startsWith('/_synapse/')) return;
171
+ e.preventDefault();
172
+ navigate(url.href, true);
173
+ });
174
+
175
+ window.addEventListener('popstate', function() {
176
+ navigate(location.href, false);
177
+ });
178
+ })();`;
179
+
180
+ export function httpStatusForError(error: unknown): number {
181
+ const code = typeof error === 'string' ? error : '';
182
+
183
+ for (const entry of ERROR_STATUS) {
184
+ if (entry.pattern.test(code)) {
185
+ return entry.status;
186
+ }
187
+ }
188
+
189
+ return 400;
190
+ }
191
+
192
+ /** Escapes special HTML characters to prevent Reflected/Stored XSS. */
193
+ export function escapeHtml(str: string): string {
194
+ return str
195
+ .replace(/&/g, '&amp;')
196
+ .replace(/</g, '&lt;')
197
+ .replace(/>/g, '&gt;')
198
+ .replace(/"/g, '&quot;')
199
+ .replace(/'/g, '&#39;');
200
+ }
201
+
202
+ /** Parses Cookie request header safely into key-value pairs. */
203
+ export function parseCookies(header: string | null | undefined): Record<string, string> {
204
+ if (!header) {
205
+ return {};
206
+ }
207
+ const cookies: Record<string, string> = {};
208
+ for (const pair of header.split(';')) {
209
+ const trimmed = pair.trim();
210
+ if (!trimmed) {
211
+ continue;
212
+ }
213
+ const eqIdx = trimmed.indexOf('=');
214
+ if (eqIdx > 0) {
215
+ const key = trimmed.slice(0, eqIdx).trim();
216
+ const rawVal = trimmed.slice(eqIdx + 1).trim();
217
+ try {
218
+ cookies[key] = decodeURIComponent(rawVal);
219
+ } catch {
220
+ cookies[key] = rawVal;
221
+ }
222
+ }
223
+ }
224
+ return cookies;
225
+ }
226
+
227
+ /** One JSON line per request when SYNAPSE_LOG=json, so an app can be observed. */
228
+ export function formatLogLine(entry: {
229
+ method: string;
230
+ path: string;
231
+ status: number;
232
+ ms: number;
233
+ userId?: string | null;
234
+ roles?: string[];
235
+ }): string {
236
+ return JSON.stringify({ ts: new Date().toISOString(), ...entry });
237
+ }
238
+
239
+ export class SynapseServer {
240
+ private slices: Map<string, DiscoveredSlice> = new Map();
241
+ private baseDir: string;
242
+ private db: DatabaseClient;
243
+ private discoveryError: { code: string; message: string; candidates: string[] } | null = null;
244
+ private loadErrors: SliceLoadError[] = [];
245
+ private clientBundles = new Map<string, { url: string; builtFor: string }>();
246
+ private clientManifest: ClientManifest | null | undefined;
247
+ private httpServer: ReturnType<typeof Bun.serve> | null = null;
248
+ public config: SynapseConfig = {};
249
+ private queueEngine: QueueEngine;
250
+ private layoutComponent: React.ComponentType<any> | null = null;
251
+ private domainLayouts: Map<string, React.ComponentType<any>> = new Map();
252
+ private rateLimiter: TokenBucketRateLimiter;
253
+ private metrics = {
254
+ startTime: Date.now(),
255
+ totalRequests: 0,
256
+ statusCodes: {} as Record<number, number>,
257
+ rpcSuccessCount: 0,
258
+ rpcErrorCount: 0,
259
+ ssrRenderCount: 0,
260
+ staticFileCount: 0
261
+ };
262
+ public port: number;
263
+
264
+ constructor(baseDir: string = process.cwd(), port: number = 3000, db?: DatabaseClient) {
265
+ this.baseDir = baseDir;
266
+ this.port = port;
267
+ this.db = db || getDatabase();
268
+ this.rateLimiter = new TokenBucketRateLimiter();
269
+ this.queueEngine = new QueueEngine({
270
+ dbPath: path.join(this.baseDir, '.synapse/queue.sqlite'),
271
+ logger: createDefaultLogger('QueueEngine')
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Gracefully shuts down the HTTP server and closes database connections.
277
+ */
278
+ async stop(): Promise<void> {
279
+ if (this.httpServer) {
280
+ this.httpServer.stop(true);
281
+ this.httpServer = null;
282
+ }
283
+ this.rateLimiter.close();
284
+ this.queueEngine.close();
285
+ await this.db.close?.();
286
+ resetDatabaseInstance();
287
+ }
288
+
289
+ get database(): DatabaseClient {
290
+ return this.db;
291
+ }
292
+
293
+ get queue(): QueueEngine {
294
+ return this.queueEngine;
295
+ }
296
+
297
+ get rateLimitEngine(): TokenBucketRateLimiter {
298
+ return this.rateLimiter;
299
+ }
300
+
301
+ get serverMetrics() {
302
+ return {
303
+ startTime: this.metrics.startTime,
304
+ totalRequests: this.metrics.totalRequests,
305
+ statusCodes: { ...this.metrics.statusCodes },
306
+ rpcSuccessCount: this.metrics.rpcSuccessCount,
307
+ rpcErrorCount: this.metrics.rpcErrorCount,
308
+ ssrRenderCount: this.metrics.ssrRenderCount,
309
+ staticFileCount: this.metrics.staticFileCount
310
+ };
311
+ }
312
+
313
+ get serverConfig(): SynapseConfig {
314
+ return this.config;
315
+ }
316
+
317
+ private createActionContext(sliceName: string, session: SessionContext): ActionContext {
318
+ return createActionContext({
319
+ db: this.db,
320
+ session,
321
+ tenantId: session.tenantId,
322
+ services: (this.config.services as Record<string, unknown>) || {},
323
+ logger: createDefaultLogger(sliceName),
324
+ enqueue: (jobOrName, payload, options) => this.queueEngine.enqueue(jobOrName, payload, options)
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Discovers and registers all slices from src/slices
330
+ */
331
+ async discoverSlices(): Promise<number> {
332
+ const resolution = resolveSlicesDir(this.baseDir);
333
+
334
+ if (!resolution.ok) {
335
+ this.discoveryError = {
336
+ code: resolution.error.code,
337
+ message: resolution.error.message,
338
+ candidates: resolution.error.candidates
339
+ };
340
+ console.error(`[SynapseServer] ${resolution.error.code}: ${resolution.error.message}`);
341
+ console.error(`[SynapseServer] Candidatos examinados: ${resolution.error.candidates.join(', ')}`);
342
+ this.slices.clear();
343
+ return 0;
344
+ }
345
+
346
+ this.discoveryError = null;
347
+ const slicesDir = resolution.value.slicesDir;
348
+ const files = findSliceFiles(slicesDir);
349
+ this.slices.clear();
350
+ this.loadErrors = [];
351
+ this.layoutComponent = null;
352
+ this.domainLayouts.clear();
353
+
354
+ // Detect optional root layout (_layout.tsx or layout.tsx)
355
+ const layoutCandidates = [path.join(slicesDir, '_layout.tsx'), path.join(slicesDir, 'layout.tsx')];
356
+ for (const cand of layoutCandidates) {
357
+ if (fs.existsSync(cand)) {
358
+ try {
359
+ const layoutMod = await import(cand);
360
+ this.layoutComponent = layoutMod.default || layoutMod.Layout || null;
361
+ break;
362
+ } catch (e) {
363
+ console.error(`[SynapseServer] Falha ao carregar layout ${cand}:`, e);
364
+ }
365
+ }
366
+ }
367
+
368
+ for (const file of files) {
369
+ const rel = path.relative(slicesDir, file);
370
+ const parts = rel.split(path.sep);
371
+ const domain = parts[0] || 'general';
372
+ const name = path.basename(file, '.slice.tsx');
373
+ const key = `${domain}/${name}`;
374
+ const routePath = `/${domain}/${name}`;
375
+ const rpcPath = `/_synapse/rpc/${domain}/${name}`;
376
+
377
+ if (!this.domainLayouts.has(domain)) {
378
+ const domainDir = path.join(slicesDir, domain);
379
+ const domainCandidates = [path.join(domainDir, '_layout.tsx'), path.join(domainDir, 'layout.tsx')];
380
+ for (const cand of domainCandidates) {
381
+ if (fs.existsSync(cand)) {
382
+ try {
383
+ const domainMod = await import(cand);
384
+ const domainComp = domainMod.default || domainMod.Layout || null;
385
+ if (domainComp) {
386
+ this.domainLayouts.set(domain, domainComp);
387
+ break;
388
+ }
389
+ } catch (e) {
390
+ console.error(`[SynapseServer] Falha ao carregar layout de domínio ${cand}:`, e);
391
+ }
392
+ }
393
+ }
394
+ }
395
+
396
+ if (this.slices.has(key)) {
397
+ this.loadErrors.push({ file, message: `chave de fatia duplicada: ${key}` });
398
+ continue;
399
+ }
400
+
401
+ try {
402
+ const mod = await import(file);
403
+ let actionFn: any = null;
404
+ let componentFn: any = null;
405
+ let componentExport: string | undefined;
406
+ let loaderFn: any = null;
407
+ let webhookFn: any = null;
408
+ let metaFn: any = null;
409
+
410
+ for (const [exportName, val] of Object.entries(mod)) {
411
+ if (
412
+ isJob(exportName) &&
413
+ val &&
414
+ typeof val === 'object' &&
415
+ 'name' in (val as Record<string, unknown>) &&
416
+ 'handler' in (val as Record<string, unknown>)
417
+ ) {
418
+ this.queueEngine.registerJob(val as any);
419
+ }
420
+ if (typeof val !== 'function') {
421
+ continue;
422
+ }
423
+ if (isAction(exportName)) {
424
+ actionFn = val;
425
+ } else if (isLoader(exportName)) {
426
+ loaderFn = val;
427
+ } else if (isComponent(exportName)) {
428
+ componentFn = val;
429
+ componentExport = exportName;
430
+ } else if (isWebhook(exportName)) {
431
+ webhookFn = val;
432
+ } else if (isMeta(exportName)) {
433
+ metaFn = val;
434
+ }
435
+ }
436
+
437
+ this.slices.set(key, {
438
+ domain,
439
+ name,
440
+ key,
441
+ routePath,
442
+ rpcPath,
443
+ webhookPath: `/_synapse/webhooks/${domain}/${name}`,
444
+ filePath: file,
445
+ actionFn,
446
+ componentFn,
447
+ componentExport,
448
+ loaderFn,
449
+ metaFn,
450
+ webhookFn
451
+ });
452
+ } catch (err) {
453
+ const message = err instanceof Error ? err.message : String(err);
454
+ this.loadErrors.push({ file: path.relative(this.baseDir, file), message });
455
+ console.error(`[SynapseServer] Falha ao carregar a fatia ${file}: ${message}`);
456
+ }
457
+ }
458
+
459
+ return this.slices.size;
460
+ }
461
+
462
+ /**
463
+ * `<domain>/<name>` resolves directly. A bare name resolves only while it is
464
+ * unique across domains; ambiguity is an explicit conflict, never a guess.
465
+ */
466
+ private resolveSlice(
467
+ target: string
468
+ ): { ok: true; slice: DiscoveredSlice } | { ok: false; status: number; message: string } {
469
+ const direct = this.slices.get(target);
470
+ if (direct) {
471
+ return { ok: true, slice: direct };
472
+ }
473
+
474
+ const matches = [...this.slices.values()].filter((slice) => slice.name === target);
475
+ if (matches.length === 1) {
476
+ return { ok: true, slice: matches[0] };
477
+ }
478
+ if (matches.length > 1) {
479
+ return {
480
+ ok: false,
481
+ status: 409,
482
+ message:
483
+ `'${target}' é ambíguo entre ${matches.map((m) => m.key).join(', ')}. ` +
484
+ `Use /_synapse/rpc/<domain>/${target}.`
485
+ };
486
+ }
487
+
488
+ return { ok: false, status: 404, message: `Action para a fatia '${target}' não encontrada.` };
489
+ }
490
+
491
+ /**
492
+ * Cross-origin policy: same-origin by default. An app serving its UI from
493
+ * another origin lists them in SYNAPSE_ALLOWED_ORIGINS; anything else gets no
494
+ * CORS headers, so the browser blocks it.
495
+ */
496
+ private corsHeaders(request: Request): Record<string, string> | null {
497
+ const origin = request.headers.get('origin');
498
+ if (!origin) {
499
+ return null;
500
+ }
501
+
502
+ const allowed = (process.env.SYNAPSE_ALLOWED_ORIGINS ?? '')
503
+ .split(',')
504
+ .map((entry) => entry.trim())
505
+ .filter((entry) => entry.length > 0);
506
+
507
+ if (!allowed.includes(origin) && !allowed.includes('*')) {
508
+ return null;
509
+ }
510
+
511
+ return {
512
+ 'Access-Control-Allow-Origin': origin,
513
+ 'Access-Control-Allow-Headers': 'content-type, authorization, x-user-id, x-user-roles',
514
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
515
+ Vary: 'Origin'
516
+ };
517
+ }
518
+
519
+ /** Records request metrics and writes one JSON line per request when SYNAPSE_LOG=json. */
520
+ private logRequest(
521
+ request: Request,
522
+ pathname: string,
523
+ status: number,
524
+ startedAt: number,
525
+ session?: SessionContext,
526
+ metricType?: 'rpc_success' | 'rpc_error' | 'ssr' | 'static' | 'sse'
527
+ ): void {
528
+ this.metrics.totalRequests++;
529
+ this.metrics.statusCodes[status] = (this.metrics.statusCodes[status] || 0) + 1;
530
+ if (metricType === 'rpc_success') this.metrics.rpcSuccessCount++;
531
+ if (metricType === 'rpc_error') this.metrics.rpcErrorCount++;
532
+ if (metricType === 'ssr') this.metrics.ssrRenderCount++;
533
+ if (metricType === 'static') this.metrics.staticFileCount++;
534
+
535
+ if (process.env.SYNAPSE_LOG !== 'json') {
536
+ return;
537
+ }
538
+
539
+ process.stdout.write(
540
+ `${formatLogLine({
541
+ method: request.method,
542
+ path: pathname,
543
+ status,
544
+ ms: Math.round(performance.now() - startedAt),
545
+ userId: session?.userId || null,
546
+ roles: session?.roles?.length ? session.roles : undefined
547
+ })}\n`
548
+ );
549
+ }
550
+
551
+ private sessionFrom(request: Request): SessionContext {
552
+ const authHeader = request.headers.get('Authorization') || request.headers.get('authorization');
553
+ const userIdHeader = request.headers.get('x-user-id');
554
+ const rolesHeader = request.headers.get('x-user-roles');
555
+ const tenantHeader = request.headers.get('x-tenant-id');
556
+ const cookieHeader = request.headers.get('cookie') || request.headers.get('Cookie');
557
+ const cookies = parseCookies(cookieHeader);
558
+
559
+ // Resolves multi-tenant ID from subdomain (acme.myapp.com) or header
560
+ const host = request.headers.get('host') || '';
561
+ const hostParts = host.split(':')[0].split('.');
562
+ let subdomainTenant: string | undefined;
563
+ if (hostParts.length > 2 && hostParts[0] !== 'www' && hostParts[0] !== 'localhost') {
564
+ subdomainTenant = hostParts[0];
565
+ }
566
+ const tenantId = tenantHeader || subdomainTenant;
567
+
568
+ // Com um segredo configurado, a sessão vem OBRIGATORIAMENTE da assinatura
569
+ // criptográfica válida (Bearer header ou cookie synapse_token).
570
+ // Headers de papel (x-user-id, x-user-roles) e cookies não assinados são
571
+ // terminantemente ignorados, prevenindo bypass de autenticação.
572
+ const secret = process.env.SYNAPSE_SESSION_SECRET;
573
+ const bearer = authHeader?.startsWith('Bearer ') ? authHeader.replace('Bearer ', '') : undefined;
574
+ const token = bearer || cookies[SESSION_COOKIE];
575
+
576
+ if (secret) {
577
+ if (!token) {
578
+ return AnonymousSession(tenantId);
579
+ }
580
+ const verified = verifySessionToken(token, secret);
581
+ return verified.ok
582
+ ? createSession({
583
+ userId: verified.value.userId,
584
+ tenantId: (verified.value as { tenantId?: string }).tenantId || tenantId,
585
+ roles: verified.value.roles,
586
+ token
587
+ })
588
+ : AnonymousSession(tenantId);
589
+ }
590
+
591
+ // Modo de desenvolvimento (sem segredo configurado):
592
+ // Aceita headers diretos ou cookies para conveniência local.
593
+ const cookieRoles = cookies[ROLES_COOKIE];
594
+ if (authHeader || userIdHeader || rolesHeader || cookieRoles || token) {
595
+ const rawRoles = rolesHeader || cookieRoles;
596
+ const roles = rawRoles
597
+ ? rawRoles
598
+ .split(',')
599
+ .map((role) => role.trim())
600
+ .filter((role) => role.length > 0)
601
+ : ['user'];
602
+
603
+ return createSession({
604
+ userId: userIdHeader || (cookies[SESSION_COOKIE] ? 'user-cookie' : `user-${token?.slice(0, 8) || 'header'}`),
605
+ tenantId,
606
+ roles,
607
+ token: token || undefined
608
+ });
609
+ }
610
+
611
+ return AnonymousSession(tenantId);
612
+ }
613
+
614
+ /**
615
+ * Builds the browser bundle for a slice from the splitter's client artifact.
616
+ *
617
+ * The splitter is what keeps database code out of the browser: only its
618
+ * `client.tsx` (UI plus RPC stubs) is bundled, with the generated entry that
619
+ * hydrates the component and wires `onSubmitAction` to the slice endpoint.
620
+ * Cached per mtime, so editing a slice rebuilds it on the next request.
621
+ */
622
+ private async ensureClientBundle(slice: DiscoveredSlice): Promise<string | null> {
623
+ if (!slice.componentExport) {
624
+ return null;
625
+ }
626
+
627
+ const signature = String(fs.statSync(slice.filePath).mtimeMs);
628
+ const cached = this.clientBundles.get(slice.key);
629
+ if (cached?.builtFor === signature) {
630
+ return cached.url;
631
+ }
632
+
633
+ // `synapse build` já empacotou esta fatia e o arquivo não mudou desde então:
634
+ // o runtime serve o artefato pronto em vez de refazer o trabalho no primeiro request.
635
+ if (this.clientManifest === undefined) {
636
+ this.clientManifest = readClientManifest(this.baseDir);
637
+ }
638
+
639
+ const prebuilt = freshBundleFrom(this.clientManifest, slice.key, Number(signature));
640
+ if (prebuilt) {
641
+ this.clientBundles.set(slice.key, { url: prebuilt, builtFor: signature });
642
+ return prebuilt;
643
+ }
644
+
645
+ const built = await buildClientBundle(
646
+ {
647
+ key: slice.key,
648
+ name: slice.name,
649
+ domain: slice.domain,
650
+ filePath: slice.filePath,
651
+ rpcPath: slice.rpcPath
652
+ },
653
+ this.baseDir
654
+ );
655
+
656
+ if (!built.ok) {
657
+ console.error(`[SynapseServer] Nao foi possivel empacotar ${slice.key}: ${built.error.message}`);
658
+ return null;
659
+ }
660
+
661
+ this.clientBundles.set(slice.key, { url: built.value.url, builtFor: signature });
662
+
663
+ return built.value.url;
664
+ }
665
+
666
+ /**
667
+ * Returns stylesheet links, respecting SYNAPSE_DISABLE_CDN and detecting public/synapse.css
668
+ */
669
+ private renderStylesheets(): string {
670
+ const disableCdn = process.env.SYNAPSE_DISABLE_CDN === 'true' || process.env.SYNAPSE_DISABLE_CDN === '1';
671
+ const localCssPath = path.join(this.baseDir, 'public', 'synapse.css');
672
+ const hasLocalCss = fs.existsSync(localCssPath);
673
+
674
+ const elements: string[] = [];
675
+ if (!disableCdn) {
676
+ elements.push(' <script src="https://cdn.tailwindcss.com"></script>');
677
+ elements.push(' <link rel="preconnect" href="https://fonts.googleapis.com">');
678
+ elements.push(' <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
679
+ elements.push(
680
+ ' <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">'
681
+ );
682
+ }
683
+ if (hasLocalCss) {
684
+ elements.push(' <link rel="stylesheet" href="/synapse.css">');
685
+ }
686
+ return elements.join('\n');
687
+ }
688
+
689
+ /**
690
+ * Generates the SSR HTML Shell with Inter font, Tailwind and client hydration
691
+ */
692
+ private renderHtmlShell(
693
+ title: string,
694
+ contentHtml: string,
695
+ sliceName: string,
696
+ propsJson: string,
697
+ clientUrl: string | null,
698
+ meta?: SliceMetadata,
699
+ locale = 'pt-BR'
700
+ ): string {
701
+ const safeTitle = escapeHtml(title);
702
+ const safeSliceName = escapeHtml(sliceName);
703
+ const pageTitle = meta?.title ? escapeHtml(meta.title) : `${safeTitle} | SynapseJS`;
704
+
705
+ const metaTags: string[] = [];
706
+ if (meta?.description) {
707
+ metaTags.push(` <meta name="description" content="${escapeHtml(meta.description)}">`);
708
+ metaTags.push(` <meta property="og:description" content="${escapeHtml(meta.description)}">`);
709
+ }
710
+ if (meta?.keywords && meta.keywords.length > 0) {
711
+ metaTags.push(` <meta name="keywords" content="${escapeHtml(meta.keywords.join(', '))}">`);
712
+ }
713
+ metaTags.push(` <meta property="og:title" content="${meta?.title ? escapeHtml(meta.title) : safeTitle}">`);
714
+ if (meta?.ogImage) {
715
+ metaTags.push(` <meta property="og:image" content="${escapeHtml(meta.ogImage)}">`);
716
+ }
717
+ if (meta?.canonical) {
718
+ metaTags.push(` <link rel="canonical" href="${escapeHtml(meta.canonical)}">`);
719
+ }
720
+ if (meta?.noIndex) {
721
+ metaTags.push(` <meta name="robots" content="noindex, nofollow">`);
722
+ }
723
+ if (meta?.extraTags) {
724
+ for (const tag of meta.extraTags) {
725
+ const nameAttr = tag.name ? ` name="${escapeHtml(tag.name)}"` : '';
726
+ const propAttr = tag.property ? ` property="${escapeHtml(tag.property)}"` : '';
727
+ metaTags.push(` <meta${nameAttr}${propAttr} content="${escapeHtml(tag.content)}">`);
728
+ }
729
+ }
730
+ const metaSection = metaTags.length > 0 ? `\n${metaTags.join('\n')}` : '';
731
+
732
+ return `<!DOCTYPE html>
733
+ <html lang="${escapeHtml(locale)}" class="h-full bg-slate-950 text-slate-100">
734
+ <head>
735
+ <meta charset="UTF-8">
736
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
737
+ <title>${pageTitle}</title>${metaSection}
738
+ ${this.renderStylesheets()}
739
+ <script type="importmap">
740
+ {
741
+ "imports": {
742
+ "react": "/_synapse/client/_vendor.js",
743
+ "react-dom": "/_synapse/client/_vendor.js",
744
+ "react-dom/client": "/_synapse/client/_vendor.js",
745
+ "synapsejs/client": "/_synapse/client/_vendor.js"
746
+ }
747
+ }
748
+ </script>
749
+ <script type="module" src="/_synapse/client/_vendor.js"></script>
750
+ <style>
751
+ body { font-family: 'Inter', sans-serif; }
752
+ code, pre { font-family: 'JetBrains Mono', monospace; }
753
+ </style>
754
+ </head>
755
+ <body class="h-full flex flex-col justify-between">
756
+ <!-- Top Bar -->
757
+ <header class="border-b border-slate-800 bg-slate-900/50 backdrop-blur px-6 py-4 flex items-center justify-between">
758
+ <div class="flex items-center gap-3">
759
+ <div class="h-8 w-8 rounded-lg bg-gradient-to-tr from-cyan-500 to-blue-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-500/20">
760
+ ⚡
761
+ </div>
762
+ <div>
763
+ <a href="/" class="font-bold tracking-tight text-white hover:text-cyan-400 transition-colors">SynapseJS</a>
764
+ <span class="text-xs text-slate-400 ml-2 px-2 py-0.5 rounded-full bg-slate-800 border border-slate-700">Machine-Centric Runtime</span>
765
+ </div>
766
+ </div>
767
+ <div class="flex items-center gap-4 text-xs font-mono text-slate-400">
768
+ <a href="/_synapse/api/repo-map" target="_blank" class="hover:text-cyan-400 hover:underline">repo-map.d.ts</a>
769
+ <a href="/_synapse/api/health" target="_blank" class="hover:text-cyan-400 hover:underline">status: online</a>
770
+ </div>
771
+ </header>
772
+
773
+ <!-- Main Container -->
774
+ <main class="flex-1 max-w-4xl w-full mx-auto p-6 md:p-10">
775
+ <div class="mb-6 flex items-center justify-between">
776
+ <div>
777
+ <h1 class="text-2xl font-bold text-white tracking-tight">${safeTitle}</h1>
778
+ <p class="text-sm text-slate-400 font-mono mt-1">Fatia Vertical: <span class="text-cyan-400">${safeSliceName}.slice.tsx</span></p>
779
+ </div>
780
+ <a href="/" class="text-xs px-3 py-1.5 rounded-md bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 transition-colors">
781
+ ← Voltar ao Hub
782
+ </a>
783
+ </div>
784
+
785
+ <!-- Slice Render Container -->
786
+ <div id="synapse-root" class="bg-slate-900/80 border border-slate-800 rounded-xl p-6 shadow-2xl backdrop-blur">${contentHtml}</div>
787
+
788
+ <!-- Props de hidratacao e a entrada de cliente gerada -->
789
+ <script data-synapse-props>globalThis.__SYNAPSE_PROPS__ = ${propsJson};</script>
790
+ ${clientUrl ? `<script data-synapse-client type="module" src="${clientUrl}"></script>` : '<!-- sem componente hidratavel -->'}
791
+
792
+ <!-- Turbo Morphing SPA Navigation (<1.5kb, Zero Dependencies) -->
793
+ <script type="module" src="/_synapse/turbo-router.js"></script>
794
+ </main>
795
+
796
+ <!-- Footer -->
797
+ <footer class="border-t border-slate-900 bg-slate-950 px-6 py-4 text-center text-xs text-slate-500">
798
+ SynapseJS Machine-Centric Engine • Deterministic Vertical Slices • Zero-Wiring Bun Server
799
+ </footer>
800
+ </body>
801
+ </html>`;
802
+ }
803
+
804
+ /**
805
+ * Generates the Main Hub Dashboard HTML
806
+ */
807
+ private renderDashboardHtml(): string {
808
+ const sliceList = Array.from(this.slices.values())
809
+ .map(
810
+ (s) => `
811
+ <div class="p-5 bg-slate-900/60 border border-slate-800 rounded-xl hover:border-cyan-500/50 transition-all flex flex-col justify-between">
812
+ <div>
813
+ <div class="flex items-center justify-between mb-2">
814
+ <span class="text-xs font-mono uppercase tracking-wider text-cyan-400 bg-cyan-950/60 border border-cyan-800/60 px-2 py-0.5 rounded">
815
+ ${escapeHtml(s.domain)}
816
+ </span>
817
+ <span class="text-xs text-emerald-400 font-mono flex items-center gap-1.5">
818
+ <span class="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse"></span> Pronto
819
+ </span>
820
+ </div>
821
+ <h3 class="text-lg font-semibold text-white mb-1">${escapeHtml(s.name)}</h3>
822
+ <p class="text-xs text-slate-400 font-mono mb-4 truncate">${escapeHtml(s.filePath)}</p>
823
+ </div>
824
+ <div class="flex items-center gap-2 pt-3 border-t border-slate-800/80">
825
+ <a href="${escapeHtml(s.routePath)}" class="flex-1 text-center py-2 px-3 rounded-lg bg-cyan-600 hover:bg-cyan-500 text-white text-xs font-medium transition-colors">
826
+ Abrir Interface UI →
827
+ </a>
828
+ <span class="text-xs font-mono text-slate-500 bg-slate-800/80 px-2 py-2 rounded-lg" title="RPC Endpoint">
829
+ POST ${escapeHtml(s.rpcPath)}
830
+ </span>
831
+ </div>
832
+ </div>
833
+ `
834
+ )
835
+ .join('');
836
+
837
+ return `<!DOCTYPE html>
838
+ <html lang="pt-BR" class="h-full bg-slate-950 text-slate-100">
839
+ <head>
840
+ <meta charset="UTF-8">
841
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
842
+ <title>Hub de Fatias Verticais | SynapseJS</title>
843
+ ${this.renderStylesheets()}
844
+ <style>body { font-family: 'Inter', sans-serif; }</style>
845
+ </head>
846
+ <body class="h-full flex flex-col justify-between">
847
+ <header class="border-b border-slate-800 bg-slate-900/50 backdrop-blur px-8 py-5 flex items-center justify-between">
848
+ <div class="flex items-center gap-3">
849
+ <div class="h-9 w-9 rounded-lg bg-gradient-to-tr from-cyan-500 to-blue-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-500/20">
850
+ ⚡
851
+ </div>
852
+ <div>
853
+ <h1 class="text-xl font-bold tracking-tight text-white">SynapseJS Runtime</h1>
854
+ <p class="text-xs text-slate-400 font-mono">Fullstack Machine-Centric Engine</p>
855
+ </div>
856
+ </div>
857
+ <div class="flex items-center gap-3">
858
+ <a href="/_synapse/api/repo-map" target="_blank" class="text-xs font-mono px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 text-cyan-400 hover:bg-slate-700 transition-colors">
859
+ Ver repo-map.d.ts (< 3k tokens)
860
+ </a>
861
+ </div>
862
+ </header>
863
+
864
+ <main class="flex-1 max-w-6xl w-full mx-auto p-8">
865
+ <div class="mb-8">
866
+ <h2 class="text-2xl font-bold text-white tracking-tight">Fatias Verticais Descobertas</h2>
867
+ <p class="text-sm text-slate-400 mt-1">Roteamento autônomo baseado no sistema de arquivos (Zero-Wiring Routing via AST).</p>
868
+ </div>
869
+
870
+ ${
871
+ this.loadErrors.length > 0
872
+ ? `<div class="mb-8 p-5 rounded-xl border border-rose-800 bg-rose-950/50">
873
+ <h3 class="text-rose-300 font-semibold mb-2">${this.loadErrors.length} fatia(s) falharam ao carregar</h3>
874
+ <ul class="text-xs font-mono text-rose-200 space-y-1">
875
+ ${this.loadErrors.map((error) => `<li>${escapeHtml(error.file)}: ${escapeHtml(error.message)}</li>`).join('')}
876
+ </ul>
877
+ </div>`
878
+ : ''
879
+ }
880
+
881
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
882
+ ${sliceList}
883
+ </div>
884
+ </main>
885
+
886
+ <footer class="border-t border-slate-900 bg-slate-950 px-8 py-4 text-center text-xs text-slate-500">
887
+ SynapseJS • ${this.slices.size} fatias carregadas • SQLite conectado em .synapse/synapse.sqlite
888
+ </footer>
889
+ </body>
890
+ </html>`;
891
+ }
892
+
893
+ /**
894
+ * Starts the Bun.serve HTTP server
895
+ */
896
+ async start() {
897
+ const loadedConfig = await loadSynapseConfig(this.baseDir);
898
+ this.config = {
899
+ ...loadedConfig,
900
+ ...this.config,
901
+ plugins: [...(loadedConfig.plugins || []), ...(this.config?.plugins || [])]
902
+ };
903
+
904
+ // 0. Auto-run declarative slice migrations on startup
905
+ await runSliceMigrations(this.baseDir, this.db);
906
+
907
+ if (this.config.plugins) {
908
+ for (const plugin of this.config.plugins) {
909
+ if (plugin.onMigrate) {
910
+ await plugin.onMigrate(this.db);
911
+ }
912
+ if (plugin.onBootstrap) {
913
+ await plugin.onBootstrap(this);
914
+ }
915
+ }
916
+ }
917
+
918
+ this.httpServer = Bun.serve({
919
+ port: this.port,
920
+ fetch: async (req: Request) => {
921
+ if (this.config.plugins) {
922
+ for (const plugin of this.config.plugins) {
923
+ if (plugin.onRequest) {
924
+ const intercepted = await plugin.onRequest(req);
925
+ if (intercepted instanceof Response) {
926
+ return intercepted;
927
+ }
928
+ }
929
+ }
930
+ }
931
+
932
+ const handleRequest = async (): Promise<Response> => {
933
+ const url = new URL(req.url);
934
+ const pathname = url.pathname;
935
+ const startedAt = performance.now();
936
+
937
+ // Arquivos estáticos primeiro: `public/` do app, estritamente contido no diretório.
938
+ if (!pathname.startsWith('/_synapse/') && pathname !== '/') {
939
+ const publicDir = path.resolve(this.baseDir, 'public');
940
+ const relative = pathname.replace(/^\/+/, '');
941
+ const candidate = path.resolve(publicDir, relative);
942
+
943
+ if (
944
+ relative.length > 0 &&
945
+ candidate.startsWith(publicDir + path.sep) &&
946
+ fs.existsSync(candidate) &&
947
+ fs.statSync(candidate).isFile()
948
+ ) {
949
+ const served = new Response(Bun.file(candidate));
950
+ this.logRequest(req, pathname, served.status, startedAt, undefined, 'static');
951
+ return served;
952
+ }
953
+ }
954
+
955
+ // 1. Dashboard Hub
956
+ if (pathname === '/' || pathname === '/index.html') {
957
+ this.logRequest(req, pathname, 200, startedAt);
958
+ return new Response(this.renderDashboardHtml(), {
959
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
960
+ });
961
+ }
962
+
963
+ // 2. Machine Endpoints for AI
964
+ if (pathname === '/_synapse/api/repo-map') {
965
+ const repoMapPath = path.join(this.baseDir, '.codebase/repo-map.d.ts');
966
+ if (fs.existsSync(repoMapPath)) {
967
+ const content = fs.readFileSync(repoMapPath, 'utf-8');
968
+ this.logRequest(req, pathname, 200, startedAt);
969
+ return new Response(content, {
970
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' }
971
+ });
972
+ }
973
+ this.logRequest(req, pathname, 404, startedAt);
974
+ return new Response('repo-map.d.ts not generated yet', { status: 404 });
975
+ }
976
+
977
+ if (pathname === '/_synapse/api/health') {
978
+ let dbHealthy = false;
979
+ let dbError: string | null = null;
980
+ try {
981
+ await this.db.query('SELECT 1;');
982
+ dbHealthy = true;
983
+ } catch (err) {
984
+ dbError = err instanceof Error ? err.message : String(err);
985
+ }
986
+
987
+ const healthy = dbHealthy && !this.discoveryError;
988
+ const status = healthy ? 'OK' : 'DEGRADED';
989
+ const httpStatus = healthy ? 200 : 503;
990
+
991
+ this.logRequest(req, pathname, httpStatus, startedAt);
992
+ return Response.json(
993
+ {
994
+ status,
995
+ framework: 'SynapseJS',
996
+ version: '1.1.0',
997
+ uptime: process.uptime(),
998
+ database: dbHealthy ? 'connected' : 'disconnected',
999
+ ...(dbError ? { databaseError: dbError } : {}),
1000
+ slicesLoaded: this.slices.size,
1001
+ slicesResolutionError: this.discoveryError,
1002
+ loadErrors: this.loadErrors,
1003
+ slices: Array.from(this.slices.values()).map((s) => ({
1004
+ domain: s.domain,
1005
+ name: s.name,
1006
+ route: s.routePath,
1007
+ rpc: s.rpcPath
1008
+ }))
1009
+ },
1010
+ { status: httpStatus }
1011
+ );
1012
+ }
1013
+
1014
+ if (pathname === '/_synapse/api/metrics') {
1015
+ const uptimeSeconds = Math.round((Date.now() - this.metrics.startTime) / 1000);
1016
+ const formatParam = url.searchParams.get('format');
1017
+ const acceptHeader = req.headers.get('accept') || '';
1018
+
1019
+ this.logRequest(req, pathname, 200, startedAt);
1020
+
1021
+ if (formatParam === 'prometheus' || acceptHeader.includes('text/plain')) {
1022
+ const promText =
1023
+ [
1024
+ '# HELP synapse_uptime_seconds SynapseJS server uptime in seconds',
1025
+ '# TYPE synapse_uptime_seconds gauge',
1026
+ `synapse_uptime_seconds ${uptimeSeconds}`,
1027
+ '# HELP synapse_requests_total Total number of HTTP requests received',
1028
+ '# TYPE synapse_requests_total counter',
1029
+ `synapse_requests_total ${this.metrics.totalRequests}`,
1030
+ '# HELP synapse_rpc_success_total Total successful RPC action calls',
1031
+ '# TYPE synapse_rpc_success_total counter',
1032
+ `synapse_rpc_success_total ${this.metrics.rpcSuccessCount}`,
1033
+ '# HELP synapse_rpc_error_total Total failed RPC action calls',
1034
+ '# TYPE synapse_rpc_error_total counter',
1035
+ `synapse_rpc_error_total ${this.metrics.rpcErrorCount}`,
1036
+ '# HELP synapse_ssr_renders_total Total server-side rendered pages',
1037
+ '# TYPE synapse_ssr_renders_total counter',
1038
+ `synapse_ssr_renders_total ${this.metrics.ssrRenderCount}`
1039
+ ].join('\n') + '\n';
1040
+
1041
+ return new Response(promText, {
1042
+ headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }
1043
+ });
1044
+ }
1045
+
1046
+ return Response.json({
1047
+ uptimeSeconds,
1048
+ totalRequests: this.metrics.totalRequests,
1049
+ statusCodes: this.metrics.statusCodes,
1050
+ rpcSuccessCount: this.metrics.rpcSuccessCount,
1051
+ rpcErrorCount: this.metrics.rpcErrorCount,
1052
+ ssrRenderCount: this.metrics.ssrRenderCount,
1053
+ staticFileCount: this.metrics.staticFileCount
1054
+ });
1055
+ }
1056
+
1057
+ // 3. Rate Limiter for Sensitive Endpoints (RPC, Uploads, Webhooks)
1058
+ if (
1059
+ pathname.startsWith('/_synapse/rpc/') ||
1060
+ pathname.startsWith('/_synapse/files/') ||
1061
+ pathname.startsWith('/_synapse/webhooks/')
1062
+ ) {
1063
+ const clientKey =
1064
+ req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
1065
+ req.headers.get('cf-connecting-ip') ||
1066
+ req.headers.get('authorization') ||
1067
+ 'global';
1068
+
1069
+ const rateResult = this.rateLimiter.consume(clientKey);
1070
+ if (!rateResult.allowed) {
1071
+ const cors = this.corsHeaders(req);
1072
+ this.logRequest(req, pathname, 429, startedAt);
1073
+ return new Response(
1074
+ JSON.stringify({
1075
+ ok: false,
1076
+ error: 'RATE_LIMIT_EXCEEDED',
1077
+ message: 'Muitas requisições. Tente novamente mais tarde.'
1078
+ }),
1079
+ {
1080
+ status: 429,
1081
+ headers: {
1082
+ 'Content-Type': 'application/json; charset=utf-8',
1083
+ 'Retry-After': String(Math.ceil(rateResult.resetMs / 1000)),
1084
+ 'X-RateLimit-Limit': String(rateResult.limit),
1085
+ 'X-RateLimit-Remaining': String(rateResult.remaining),
1086
+ 'X-RateLimit-Reset': String(Math.ceil((Date.now() + rateResult.resetMs) / 1000)),
1087
+ ...(cors ?? {})
1088
+ }
1089
+ }
1090
+ );
1091
+ }
1092
+ }
1093
+
1094
+ // 3. RPC Actions Dispatcher (POST /_synapse/rpc/:sliceName)
1095
+ if (pathname.startsWith('/_synapse/rpc/')) {
1096
+ const cors = this.corsHeaders(req);
1097
+
1098
+ if (req.method === 'OPTIONS') {
1099
+ const status = cors ? 204 : 404;
1100
+ this.logRequest(req, pathname, status, startedAt);
1101
+ return new Response(null, { status, headers: cors ?? undefined });
1102
+ }
1103
+
1104
+ if (req.method !== 'POST') {
1105
+ this.logRequest(req, pathname, 405, startedAt);
1106
+ return new Response('Method Not Allowed', { status: 405, headers: cors ?? undefined });
1107
+ }
1108
+
1109
+ // CSRF: um formulario de outra origem so consegue mandar
1110
+ // urlencoded/plain/text, nunca application/json.
1111
+ if (!(req.headers.get('content-type') ?? '').toLowerCase().includes('application/json')) {
1112
+ this.logRequest(req, pathname, 415, startedAt);
1113
+ return Response.json(
1114
+ { ok: false, error: 'Requisicao RPC exige Content-Type: application/json.' },
1115
+ { status: 415, headers: cors ?? undefined }
1116
+ );
1117
+ }
1118
+
1119
+ const target = pathname.replace('/_synapse/rpc/', '');
1120
+ const resolved = this.resolveSlice(target);
1121
+
1122
+ if (!resolved.ok) {
1123
+ this.logRequest(req, pathname, resolved.status, startedAt);
1124
+ return Response.json({ ok: false, error: resolved.message }, { status: resolved.status });
1125
+ }
1126
+
1127
+ const slice = resolved.slice;
1128
+ if (!slice.actionFn) {
1129
+ this.logRequest(req, pathname, 404, startedAt);
1130
+ return Response.json(
1131
+ { ok: false, error: `A fatia '${slice.key}' não exporta nenhuma *Action.` },
1132
+ { status: 404 }
1133
+ );
1134
+ }
1135
+
1136
+ try {
1137
+ const body = await req.json();
1138
+ const session = this.sessionFrom(req);
1139
+ const actionCtx = this.createActionContext(slice.name, session);
1140
+
1141
+ const result = await slice.actionFn(body, actionCtx, session, actionCtx);
1142
+ const status = result.ok ? 200 : httpStatusForError(result.error);
1143
+ const metricType = result.ok ? 'rpc_success' : 'rpc_error';
1144
+ this.logRequest(req, pathname, status, startedAt, session, metricType);
1145
+
1146
+ return Response.json(result, { status, headers: cors ?? undefined });
1147
+ } catch (err: any) {
1148
+ this.logRequest(req, pathname, 500, startedAt, undefined, 'rpc_error');
1149
+ return Response.json({ ok: false, error: `Falha interna no RPC: ${err.message}` }, { status: 500 });
1150
+ }
1151
+ }
1152
+
1153
+ // 4. Webhooks Gateway (POST /_synapse/webhooks/:sliceName)
1154
+ if (pathname.startsWith('/_synapse/webhooks/')) {
1155
+ const cors = this.corsHeaders(req);
1156
+
1157
+ if (req.method === 'OPTIONS') {
1158
+ const status = cors ? 204 : 404;
1159
+ this.logRequest(req, pathname, status, startedAt);
1160
+ return new Response(null, { status, headers: cors ?? undefined });
1161
+ }
1162
+
1163
+ if (req.method !== 'POST') {
1164
+ this.logRequest(req, pathname, 405, startedAt);
1165
+ return new Response('Method Not Allowed', { status: 405, headers: cors ?? undefined });
1166
+ }
1167
+
1168
+ const target = pathname.replace('/_synapse/webhooks/', '');
1169
+ const resolved = this.resolveSlice(target);
1170
+
1171
+ if (!resolved.ok) {
1172
+ this.logRequest(req, pathname, resolved.status, startedAt);
1173
+ return Response.json({ ok: false, error: resolved.message }, { status: resolved.status });
1174
+ }
1175
+
1176
+ const slice = resolved.slice;
1177
+ if (!slice.webhookFn) {
1178
+ this.logRequest(req, pathname, 404, startedAt);
1179
+ return Response.json(
1180
+ { ok: false, error: `A fatia '${slice.key}' não exporta nenhum *Webhook handler.` },
1181
+ { status: 404 }
1182
+ );
1183
+ }
1184
+
1185
+ try {
1186
+ const rawBody = new Uint8Array(await req.arrayBuffer());
1187
+ const bodyText = new TextDecoder().decode(rawBody);
1188
+ let json: unknown = null;
1189
+ try {
1190
+ json = JSON.parse(bodyText);
1191
+ } catch {
1192
+ json = null;
1193
+ }
1194
+
1195
+ const session = this.sessionFrom(req);
1196
+ const actionCtx = this.createActionContext(slice.name, session);
1197
+
1198
+ const result = await slice.webhookFn(
1199
+ {
1200
+ rawBody,
1201
+ bodyText,
1202
+ json,
1203
+ headers: req.headers
1204
+ },
1205
+ actionCtx
1206
+ );
1207
+
1208
+ const status = typeof result === 'object' && result !== null && 'ok' in result && !result.ok ? 400 : 200;
1209
+
1210
+ this.logRequest(req, pathname, status, startedAt, session);
1211
+ if (result === undefined || result === null) {
1212
+ return new Response('OK', { status: 200, headers: cors ?? undefined });
1213
+ }
1214
+ return Response.json(result, { status, headers: cors ?? undefined });
1215
+ } catch (err: unknown) {
1216
+ const message = err instanceof Error ? err.message : String(err);
1217
+ this.logRequest(req, pathname, 500, startedAt);
1218
+ return Response.json(
1219
+ { ok: false, error: `Falha no processamento de webhook: ${message}` },
1220
+ { status: 500 }
1221
+ );
1222
+ }
1223
+ }
1224
+
1225
+ // 4.5. Realtime SSE Gateway (GET /_synapse/sse/:topic*)
1226
+ if (pathname.startsWith('/_synapse/sse/')) {
1227
+ const cors = this.corsHeaders(req);
1228
+
1229
+ if (req.method === 'OPTIONS') {
1230
+ return new Response(null, { status: cors ? 204 : 404, headers: cors ?? undefined });
1231
+ }
1232
+
1233
+ if (req.method !== 'GET') {
1234
+ return new Response('Method Not Allowed', { status: 405, headers: cors ?? undefined });
1235
+ }
1236
+
1237
+ const rawTopic = pathname.replace('/_synapse/sse/', '');
1238
+ const topic = decodeURIComponent(rawTopic);
1239
+
1240
+ if (!topic) {
1241
+ return Response.json({ ok: false, error: 'Tópico de subscrição inválido.' }, { status: 400 });
1242
+ }
1243
+
1244
+ const eventHub = getEventHub();
1245
+ const session = this.sessionFrom(req);
1246
+ let unsubscribe: (() => void) | null = null;
1247
+ let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
1248
+
1249
+ const stream = new ReadableStream({
1250
+ start(controller) {
1251
+ const encoder = new TextEncoder();
1252
+ controller.enqueue(encoder.encode(': connected\n\n'));
1253
+
1254
+ keepAliveTimer = setInterval(() => {
1255
+ try {
1256
+ controller.enqueue(encoder.encode(': keep-alive\n\n'));
1257
+ } catch {
1258
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
1259
+ }
1260
+ }, 15000);
1261
+
1262
+ unsubscribe = eventHub.subscribe(topic, (data) => {
1263
+ try {
1264
+ const eventData = JSON.stringify(data);
1265
+ controller.enqueue(encoder.encode(`event: message\ndata: ${eventData}\n\n`));
1266
+ } catch (err) {
1267
+ console.error(`[SSE] Erro ao serializar evento para tópico "${topic}":`, err);
1268
+ }
1269
+ });
1270
+ },
1271
+ cancel() {
1272
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
1273
+ if (unsubscribe) unsubscribe();
1274
+ }
1275
+ });
1276
+
1277
+ this.logRequest(req, pathname, 200, startedAt, session, 'sse');
1278
+
1279
+ const sseHeaders: Record<string, string> = {
1280
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1281
+ 'Cache-Control': 'no-cache, no-transform',
1282
+ Connection: 'keep-alive',
1283
+ ...(cors ?? {})
1284
+ };
1285
+
1286
+ return new Response(stream, { headers: sseHeaders });
1287
+ }
1288
+
1289
+ // 5. Uploads (o RPC é JSON de propósito; arquivo precisa de outra porta)
1290
+ if (pathname.startsWith('/_synapse/files/')) {
1291
+ const cors = this.corsHeaders(req);
1292
+
1293
+ if (req.method === 'OPTIONS') {
1294
+ return new Response(null, { status: cors ? 204 : 404, headers: cors ?? undefined });
1295
+ }
1296
+
1297
+ if (req.method !== 'POST') {
1298
+ return new Response('Method Not Allowed', { status: 405, headers: cors ?? undefined });
1299
+ }
1300
+
1301
+ const uploadTarget = pathname.replace('/_synapse/files/', '');
1302
+ const resolvedUpload = this.resolveSlice(uploadTarget);
1303
+
1304
+ if (!resolvedUpload.ok) {
1305
+ return Response.json({ ok: false, error: resolvedUpload.message }, { status: resolvedUpload.status });
1306
+ }
1307
+
1308
+ const outcome = await saveUpload(req, {
1309
+ baseDir: this.baseDir,
1310
+ domain: resolvedUpload.slice.domain,
1311
+ session: this.sessionFrom(req)
1312
+ });
1313
+
1314
+ this.logRequest(req, pathname, outcome.status, startedAt);
1315
+
1316
+ return Response.json(outcome.body, { status: outcome.status, headers: cors ?? undefined });
1317
+ }
1318
+
1319
+ // 5. Image Optimization on-demand (DEF-02)
1320
+ if (pathname === '/_synapse/images/optimize') {
1321
+ const cors = this.corsHeaders(req);
1322
+ if (req.method === 'OPTIONS') {
1323
+ return new Response(null, { status: cors ? 204 : 404, headers: cors ?? undefined });
1324
+ }
1325
+
1326
+ const fileUrl = url.searchParams.get('url');
1327
+ const widthParam = url.searchParams.get('w');
1328
+ const heightParam = url.searchParams.get('h');
1329
+ const qualityParam = url.searchParams.get('q');
1330
+ const formatParam = url.searchParams.get('format') as 'webp' | 'jpeg' | 'png' | 'avif' | null;
1331
+
1332
+ if (!fileUrl) {
1333
+ return Response.json({ ok: false, error: 'MISSING_URL_PARAM' }, { status: 400 });
1334
+ }
1335
+
1336
+ try {
1337
+ let buffer: Uint8Array;
1338
+ let mimeType = 'image/jpeg';
1339
+
1340
+ if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
1341
+ const res = await fetch(fileUrl);
1342
+ if (!res.ok) {
1343
+ return Response.json({ ok: false, error: 'FETCH_IMAGE_FAILED' }, { status: 502 });
1344
+ }
1345
+ buffer = new Uint8Array(await res.arrayBuffer());
1346
+ mimeType = res.headers.get('content-type') || mimeType;
1347
+ } else {
1348
+ const cleanRel = fileUrl.replace(/^\/+/, '');
1349
+ const candidate = path.resolve(this.baseDir, cleanRel);
1350
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
1351
+ return Response.json({ ok: false, error: 'IMAGE_NOT_FOUND' }, { status: 404 });
1352
+ }
1353
+ buffer = new Uint8Array(await Bun.file(candidate).arrayBuffer());
1354
+ }
1355
+
1356
+ const optimized = await optimizeImage(buffer, mimeType, {
1357
+ width: widthParam ? parseInt(widthParam, 10) : undefined,
1358
+ height: heightParam ? parseInt(heightParam, 10) : undefined,
1359
+ quality: qualityParam ? parseInt(qualityParam, 10) : undefined,
1360
+ format: formatParam || undefined
1361
+ });
1362
+
1363
+ return new Response(Buffer.from(optimized.data), {
1364
+ status: 200,
1365
+ headers: {
1366
+ 'Content-Type': optimized.contentType,
1367
+ 'Cache-Control': 'public, max-age=31536000, immutable',
1368
+ 'X-Synapse-Engine': optimized.engine,
1369
+ ...(cors ?? {})
1370
+ }
1371
+ });
1372
+ } catch (err: unknown) {
1373
+ const msg = err instanceof Error ? err.message : String(err);
1374
+ return Response.json({ ok: false, error: msg }, { status: 500 });
1375
+ }
1376
+ }
1377
+
1378
+ // 5. Client bundles produced by the splitter
1379
+ if (pathname === '/_synapse/turbo-router.js') {
1380
+ this.logRequest(req, pathname, 200, startedAt, undefined, 'static');
1381
+ return new Response(TURBO_ROUTER_SCRIPT, {
1382
+ headers: { 'Content-Type': 'application/javascript; charset=utf-8' }
1383
+ });
1384
+ }
1385
+
1386
+ if (pathname.startsWith('/_synapse/client/')) {
1387
+ const bundleFilename = path.basename(pathname);
1388
+ const bundleFile = path.join(this.baseDir, '.synapse/client', bundleFilename);
1389
+ if (!fs.existsSync(bundleFile) && bundleFilename === VENDOR_BUNDLE_NAME) {
1390
+ await buildVendorBundle(this.baseDir);
1391
+ }
1392
+ if (!fs.existsSync(bundleFile)) {
1393
+ this.logRequest(req, pathname, 404, startedAt);
1394
+ return new Response('Client bundle not found', { status: 404 });
1395
+ }
1396
+ this.logRequest(req, pathname, 200, startedAt, undefined, 'static');
1397
+ return new Response(Bun.file(bundleFile), {
1398
+ headers: { 'Content-Type': 'application/javascript; charset=utf-8' }
1399
+ });
1400
+ }
1401
+
1402
+ // 5. UI Route Dispatcher (GET /:domain/:sliceName with optional i18n locale prefix)
1403
+ let resolvedSlice: DiscoveredSlice | undefined;
1404
+ let matchedLocale: string | undefined;
1405
+
1406
+ for (const slice of this.slices.values()) {
1407
+ if (pathname === slice.routePath) {
1408
+ resolvedSlice = slice;
1409
+ break;
1410
+ }
1411
+ }
1412
+
1413
+ if (!resolvedSlice && !pathname.startsWith('/_synapse/')) {
1414
+ const localeMatch = pathname.match(I18N_LOCALE_REGEX);
1415
+ if (localeMatch) {
1416
+ const strippedPath = pathname.replace(I18N_LOCALE_REGEX, '/');
1417
+ for (const slice of this.slices.values()) {
1418
+ if (strippedPath === slice.routePath) {
1419
+ resolvedSlice = slice;
1420
+ matchedLocale = localeMatch[1];
1421
+ break;
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+
1427
+ if (resolvedSlice) {
1428
+ const slice = resolvedSlice;
1429
+ const session = this.sessionFrom(req);
1430
+ const actionCtx = this.createActionContext(slice.name, session);
1431
+ const params = Object.fromEntries(url.searchParams.entries());
1432
+ const dataProps: Record<string, unknown> = {
1433
+ ...params,
1434
+ ...(matchedLocale ? { locale: matchedLocale } : {})
1435
+ };
1436
+ let loaderError: string | null = null;
1437
+
1438
+ const loaderCtx: SliceLoaderContext = {
1439
+ url: url.toString(),
1440
+ params,
1441
+ db: this.db,
1442
+ session,
1443
+ ctx: actionCtx,
1444
+ services: (this.config.services as Record<string, unknown>) || {},
1445
+ locale: matchedLocale
1446
+ };
1447
+
1448
+ if (slice.loaderFn) {
1449
+ try {
1450
+ const loaded = await slice.loaderFn(loaderCtx);
1451
+ Object.assign(dataProps, loaded);
1452
+ } catch (err) {
1453
+ loaderError = err instanceof Error ? err.message : String(err);
1454
+ console.error(`[SynapseServer] Loader de ${slice.key} falhou: ${loaderError}`);
1455
+ }
1456
+ }
1457
+
1458
+ let meta: SliceMetadata | undefined;
1459
+ if (slice.metaFn && !loaderError) {
1460
+ try {
1461
+ meta = await slice.metaFn(dataProps, loaderCtx);
1462
+ } catch (err) {
1463
+ console.error(`[SynapseServer] sliceMeta de ${slice.key} falhou:`, err);
1464
+ }
1465
+ }
1466
+
1467
+ const clientUrl = loaderError ? null : await this.ensureClientBundle(slice);
1468
+
1469
+ const renderProps: Record<string, unknown> = {
1470
+ ...dataProps,
1471
+ ...(slice.actionFn
1472
+ ? {
1473
+ onSubmitAction: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx),
1474
+ action: (payload: unknown) => slice.actionFn?.(payload, actionCtx, session, actionCtx)
1475
+ }
1476
+ : {})
1477
+ };
1478
+
1479
+ let contentHtml: string;
1480
+ if (loaderError) {
1481
+ contentHtml = `<div class="text-rose-400">Falha no loader de ${escapeHtml(slice.key)}: ${escapeHtml(loaderError)}</div>`;
1482
+ } else if (slice.componentFn) {
1483
+ try {
1484
+ const sliceEl = React.createElement(slice.componentFn, renderProps);
1485
+ const domainLayout = this.domainLayouts.get(slice.domain);
1486
+ const treeWithDomainLayout = domainLayout
1487
+ ? React.createElement(domainLayout, { session, url: url.toString() }, sliceEl)
1488
+ : sliceEl;
1489
+ const treeWithLayout = this.layoutComponent
1490
+ ? React.createElement(this.layoutComponent, { session, url: url.toString() }, treeWithDomainLayout)
1491
+ : treeWithDomainLayout;
1492
+ const rootEl = React.createElement(SynapseProvider, { props: dataProps, session }, treeWithLayout);
1493
+ contentHtml = renderToString(rootEl);
1494
+ } catch (e: any) {
1495
+ const errMessage = e instanceof Error ? e.message : String(e);
1496
+ contentHtml = `<div class="text-rose-400">Erro na renderização SSR: ${escapeHtml(errMessage)}</div>`;
1497
+ }
1498
+ } else {
1499
+ contentHtml = '<div class="text-slate-400">Nenhum componente de UI exportado.</div>';
1500
+ }
1501
+
1502
+ this.logRequest(req, pathname, 200, startedAt, session, 'ssr');
1503
+
1504
+ const html = this.renderHtmlShell(
1505
+ meta?.title || slice.name,
1506
+ contentHtml,
1507
+ slice.name,
1508
+ serializeClientProps(dataProps),
1509
+ clientUrl,
1510
+ meta,
1511
+ matchedLocale || 'pt-BR'
1512
+ );
1513
+ return new Response(html, {
1514
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
1515
+ });
1516
+ }
1517
+
1518
+ this.logRequest(req, pathname, 404, startedAt);
1519
+ return new Response('404 Not Found in SynapseJS Router', { status: 404 });
1520
+ };
1521
+
1522
+ let response = await handleRequest();
1523
+ if (this.config.plugins) {
1524
+ for (const plugin of this.config.plugins) {
1525
+ if (plugin.onResponse) {
1526
+ response = await plugin.onResponse(response, req);
1527
+ }
1528
+ }
1529
+ }
1530
+ return response;
1531
+ }
1532
+ });
1533
+
1534
+ const server = this.httpServer;
1535
+ this.port = server.port ?? this.port;
1536
+ console.log(`⚡ [SynapseJS Server] Executando em http://localhost:${server.port}`);
1537
+ console.log(` - Fatias descobertas: ${this.slices.size}`);
1538
+ console.log(` - Hub Principal: http://localhost:${server.port}/`);
1539
+ console.log(` - Banco SQLite: .synapse/synapse.sqlite`);
1540
+
1541
+ return server;
1542
+ }
1543
+ }
1544
+
1545
+ if (import.meta.main) {
1546
+ const port = parseInt(process.env.PORT || '3000', 10);
1547
+ const server = new SynapseServer(process.cwd(), port);
1548
+ await server.discoverSlices();
1549
+ server.start();
1550
+ }