@syncular/server-hono 0.0.6-221 → 0.0.6-224

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/src/routes.ts CHANGED
@@ -63,7 +63,10 @@ import {
63
63
  DEFAULT_SYNC_RATE_LIMITS,
64
64
  type SyncRateLimitConfig,
65
65
  } from './rate-limit';
66
- import { isWebSocketOriginAllowed } from './websocket-origin';
66
+ import {
67
+ isWebSocketOriginAllowed,
68
+ resolveAllowedOriginFromPatterns,
69
+ } from './websocket-origin';
67
70
  import {
68
71
  createWebSocketConnection,
69
72
  createWebSocketConnectionOwnerKey,
@@ -126,33 +129,61 @@ export interface SyncWebSocketConfig {
126
129
  allowedOrigins?: string[] | '*';
127
130
  }
128
131
 
132
+ export type SyncCorsOriginResolver = (
133
+ origin: string | undefined,
134
+ context: Context
135
+ ) =>
136
+ | boolean
137
+ | string
138
+ | null
139
+ | undefined
140
+ | Promise<boolean | string | null | undefined>;
141
+
142
+ export type SyncCorsOrigin = string | string[] | '*' | SyncCorsOriginResolver;
143
+
144
+ export interface SyncCorsOptions {
145
+ /**
146
+ * Hono-style origin config.
147
+ * - string / string[]: exact or wildcard origin patterns
148
+ * - '*': allow all origins
149
+ * - function: dynamic allow/deny decision
150
+ */
151
+ origin?: SyncCorsOrigin;
152
+ /**
153
+ * Additional request headers to allow. These are appended to the built-in
154
+ * Syncular transport and tracing headers, not used as a replacement.
155
+ */
156
+ allowHeaders?: string[];
157
+ /**
158
+ * Additional response headers exposed to the browser.
159
+ */
160
+ exposeHeaders?: string[];
161
+ }
162
+
163
+ /**
164
+ * Legacy sync CORS config.
165
+ * @deprecated Prefer `cors: 'https://app.example.com'` or
166
+ * `cors: { origin: ['https://app.example.com'] }`.
167
+ */
168
+ export interface LegacySyncCorsOptions {
169
+ allowedOrigins?: string[] | '*';
170
+ resolveOrigin?: (
171
+ origin: string | undefined,
172
+ context: Context
173
+ ) => string | null | Promise<string | null>;
174
+ allowCredentials?: boolean;
175
+ allowHeaders?: string[];
176
+ allowMethods?: string[];
177
+ maxAgeSeconds?: number;
178
+ }
179
+
129
180
  export interface SyncRoutesConfigWithRateLimit {
130
181
  /**
131
182
  * Optional browser CORS handling for sync routes.
132
183
  * When configured, sync route responses and preflights include matching
133
184
  * CORS headers directly from the generated sync app.
134
185
  */
135
- cors?: {
136
- /**
137
- * Simple exact-match origin allowlist.
138
- * Use `'*'` to allow all origins.
139
- * When omitted, provide `resolveOrigin` for custom logic.
140
- */
141
- allowedOrigins?: string[] | '*';
142
- /**
143
- * Advanced origin resolver for dynamic CORS decisions.
144
- * When both `allowedOrigins` and `resolveOrigin` are provided,
145
- * `resolveOrigin` takes precedence.
146
- */
147
- resolveOrigin?: (
148
- origin: string | undefined,
149
- context: Context
150
- ) => string | null | Promise<string | null>;
151
- allowCredentials?: boolean;
152
- allowHeaders?: string[];
153
- allowMethods?: string[];
154
- maxAgeSeconds?: number;
155
- };
186
+ cors?: SyncCorsOrigin | SyncCorsOptions | LegacySyncCorsOptions;
156
187
  /**
157
188
  * Max commits per pull request.
158
189
  * Default: 100
@@ -364,11 +395,27 @@ const DEFAULT_SYNC_CORS_ALLOW_METHODS = [
364
395
  'OPTIONS',
365
396
  ];
366
397
 
398
+ const DEFAULT_SYNC_CORS_EXPOSE_HEADERS: string[] = [];
399
+
400
+ export type NormalizedSyncCorsConfig = {
401
+ resolveOrigin: (
402
+ origin: string | undefined,
403
+ context: Context
404
+ ) => Promise<string | null>;
405
+ staticAllowedOrigins?: string[] | '*';
406
+ allowHeaders: string[];
407
+ exposeHeaders: string[];
408
+ allowMethods: string[];
409
+ allowCredentials: boolean;
410
+ maxAgeSeconds: number;
411
+ };
412
+
367
413
  function applySyncCorsHeaders(args: {
368
414
  headers: Headers;
369
415
  allowedOrigin: string;
370
416
  allowCredentials: boolean;
371
417
  allowHeaders: string[];
418
+ exposeHeaders: string[];
372
419
  allowMethods: string[];
373
420
  maxAgeSeconds: number;
374
421
  }): void {
@@ -382,6 +429,12 @@ function applySyncCorsHeaders(args: {
382
429
  args.allowMethods.join(', ')
383
430
  );
384
431
  args.headers.set('Access-Control-Max-Age', String(args.maxAgeSeconds));
432
+ if (args.exposeHeaders.length > 0) {
433
+ args.headers.set(
434
+ 'Access-Control-Expose-Headers',
435
+ args.exposeHeaders.join(', ')
436
+ );
437
+ }
385
438
  if (args.allowedOrigin !== '*') {
386
439
  args.headers.append('Vary', 'Origin');
387
440
  if (args.allowCredentials) {
@@ -400,25 +453,149 @@ function createSyncCorsOriginDeniedResponse(origin: string): Response {
400
453
  );
401
454
  }
402
455
 
403
- async function resolveSyncCorsOrigin(args: {
404
- config: NonNullable<SyncRoutesConfigWithRateLimit['cors']>;
405
- origin: string | undefined;
406
- context: Context;
407
- }): Promise<string | null> {
408
- const { config, origin, context } = args;
409
- if (typeof config.resolveOrigin === 'function') {
410
- return config.resolveOrigin(origin, context);
411
- }
412
- if (config.allowedOrigins === '*') {
413
- return '*';
456
+ function mergeUniqueHeaders(...lists: Array<string[] | undefined>): string[] {
457
+ const seen = new Set<string>();
458
+ const merged: string[] = [];
459
+ for (const list of lists) {
460
+ for (const header of list ?? []) {
461
+ const trimmed = header.trim();
462
+ if (trimmed.length === 0) continue;
463
+ const key = trimmed.toLowerCase();
464
+ if (seen.has(key)) continue;
465
+ seen.add(key);
466
+ merged.push(trimmed);
467
+ }
414
468
  }
415
- if (Array.isArray(config.allowedOrigins)) {
416
- if (!origin) {
469
+ return merged;
470
+ }
471
+
472
+ function normalizeOriginResolver(
473
+ resolver: SyncCorsOriginResolver
474
+ ): NormalizedSyncCorsConfig['resolveOrigin'] {
475
+ return async (origin, context) => {
476
+ const resolved = await resolver(origin, context);
477
+ if (resolved === true) {
478
+ return origin ?? null;
479
+ }
480
+ if (resolved === false || resolved == null) {
417
481
  return null;
418
482
  }
419
- return config.allowedOrigins.includes(origin) ? origin : null;
483
+ return resolved;
484
+ };
485
+ }
486
+
487
+ function createStaticOriginResolver(
488
+ allowedOrigins: string[] | '*'
489
+ ): NormalizedSyncCorsConfig['resolveOrigin'] {
490
+ return async (origin) => {
491
+ if (allowedOrigins === '*') {
492
+ return '*';
493
+ }
494
+ return resolveAllowedOriginFromPatterns(origin, allowedOrigins);
495
+ };
496
+ }
497
+
498
+ function toStaticAllowedOrigins(
499
+ origin: string | string[] | '*'
500
+ ): string[] | '*' {
501
+ return origin === '*' ? '*' : typeof origin === 'string' ? [origin] : origin;
502
+ }
503
+
504
+ function isLegacySyncCorsOptions(
505
+ value: SyncRoutesConfigWithRateLimit['cors']
506
+ ): value is LegacySyncCorsOptions {
507
+ return (
508
+ !!value &&
509
+ typeof value === 'object' &&
510
+ !Array.isArray(value) &&
511
+ ('allowedOrigins' in value ||
512
+ 'resolveOrigin' in value ||
513
+ 'allowCredentials' in value ||
514
+ 'allowMethods' in value ||
515
+ 'maxAgeSeconds' in value)
516
+ );
517
+ }
518
+
519
+ export function normalizeSyncCorsConfig(
520
+ config: SyncRoutesConfigWithRateLimit['cors']
521
+ ): NormalizedSyncCorsConfig | null {
522
+ if (!config) {
523
+ return null;
420
524
  }
421
- return null;
525
+
526
+ if (
527
+ typeof config === 'string' ||
528
+ Array.isArray(config) ||
529
+ typeof config === 'function'
530
+ ) {
531
+ const originResolver =
532
+ typeof config === 'function'
533
+ ? normalizeOriginResolver(config)
534
+ : createStaticOriginResolver(toStaticAllowedOrigins(config));
535
+ const staticAllowedOrigins =
536
+ typeof config === 'function' ? undefined : toStaticAllowedOrigins(config);
537
+ return {
538
+ resolveOrigin: originResolver,
539
+ staticAllowedOrigins,
540
+ allowHeaders: [...DEFAULT_SYNC_CORS_ALLOW_HEADERS],
541
+ exposeHeaders: [...DEFAULT_SYNC_CORS_EXPOSE_HEADERS],
542
+ allowMethods: [...DEFAULT_SYNC_CORS_ALLOW_METHODS],
543
+ allowCredentials: true,
544
+ maxAgeSeconds: 86_400,
545
+ };
546
+ }
547
+
548
+ if (isLegacySyncCorsOptions(config)) {
549
+ const staticAllowedOrigins = config.allowedOrigins;
550
+ const resolveOrigin =
551
+ typeof config.resolveOrigin === 'function'
552
+ ? async (origin: string | undefined, context: Context) =>
553
+ (await config.resolveOrigin?.(origin, context)) ?? null
554
+ : config.allowedOrigins
555
+ ? createStaticOriginResolver(config.allowedOrigins)
556
+ : async () => null;
557
+ return {
558
+ resolveOrigin,
559
+ staticAllowedOrigins,
560
+ allowHeaders: mergeUniqueHeaders(
561
+ DEFAULT_SYNC_CORS_ALLOW_HEADERS,
562
+ config.allowHeaders
563
+ ),
564
+ exposeHeaders: [...DEFAULT_SYNC_CORS_EXPOSE_HEADERS],
565
+ allowMethods: config.allowMethods ?? [...DEFAULT_SYNC_CORS_ALLOW_METHODS],
566
+ allowCredentials: config.allowCredentials ?? true,
567
+ maxAgeSeconds: config.maxAgeSeconds ?? 86_400,
568
+ };
569
+ }
570
+
571
+ const staticOrigin = config.origin;
572
+ const resolveOrigin =
573
+ typeof staticOrigin === 'function'
574
+ ? normalizeOriginResolver(staticOrigin)
575
+ : staticOrigin
576
+ ? createStaticOriginResolver(toStaticAllowedOrigins(staticOrigin))
577
+ : async () => null;
578
+ const staticAllowedOrigins =
579
+ typeof staticOrigin === 'function'
580
+ ? undefined
581
+ : staticOrigin
582
+ ? toStaticAllowedOrigins(staticOrigin)
583
+ : undefined;
584
+ return {
585
+ resolveOrigin,
586
+ staticAllowedOrigins,
587
+ allowHeaders: mergeUniqueHeaders(
588
+ DEFAULT_SYNC_CORS_ALLOW_HEADERS,
589
+ config.allowHeaders
590
+ ),
591
+ exposeHeaders: mergeUniqueHeaders(
592
+ DEFAULT_SYNC_CORS_EXPOSE_HEADERS,
593
+ config.exposeHeaders
594
+ ),
595
+ allowMethods: [...DEFAULT_SYNC_CORS_ALLOW_METHODS],
596
+ allowCredentials: true,
597
+ maxAgeSeconds: 86_400,
598
+ };
422
599
  }
423
600
 
424
601
  function createOpaqueId(prefix: string): string {
@@ -692,36 +869,28 @@ export function createSyncRoutes<
692
869
  });
693
870
  return c.text('Internal Server Error', 500);
694
871
  });
695
- const corsConfig = config.cors;
872
+ const corsConfig = normalizeSyncCorsConfig(config.cors);
696
873
  if (corsConfig) {
697
874
  routes.use('*', async (c, next) => {
698
875
  const origin = readOriginHeader(c);
699
- const allowedOrigin = await resolveSyncCorsOrigin({
700
- config: corsConfig,
701
- origin,
702
- context: c,
703
- });
876
+ const allowedOrigin = await corsConfig.resolveOrigin(origin, c);
704
877
 
705
878
  if (origin && !allowedOrigin) {
706
879
  return createSyncCorsOriginDeniedResponse(origin);
707
880
  }
708
881
 
709
882
  const resolvedOrigin = allowedOrigin ?? '*';
710
- const allowHeaders =
711
- corsConfig.allowHeaders ?? DEFAULT_SYNC_CORS_ALLOW_HEADERS;
712
- const allowMethods =
713
- corsConfig.allowMethods ?? DEFAULT_SYNC_CORS_ALLOW_METHODS;
714
- const maxAgeSeconds = corsConfig.maxAgeSeconds ?? 86_400;
715
883
 
716
884
  if (c.req.method === 'OPTIONS') {
717
885
  const headers = new Headers();
718
886
  applySyncCorsHeaders({
719
887
  headers,
720
888
  allowedOrigin: resolvedOrigin,
721
- allowCredentials: corsConfig.allowCredentials ?? false,
722
- allowHeaders,
723
- allowMethods,
724
- maxAgeSeconds,
889
+ allowCredentials: corsConfig.allowCredentials,
890
+ allowHeaders: corsConfig.allowHeaders,
891
+ exposeHeaders: corsConfig.exposeHeaders,
892
+ allowMethods: corsConfig.allowMethods,
893
+ maxAgeSeconds: corsConfig.maxAgeSeconds,
725
894
  });
726
895
  return new Response(null, { status: 204, headers });
727
896
  }
@@ -730,10 +899,11 @@ export function createSyncRoutes<
730
899
  applySyncCorsHeaders({
731
900
  headers: c.res.headers,
732
901
  allowedOrigin: resolvedOrigin,
733
- allowCredentials: corsConfig.allowCredentials ?? false,
734
- allowHeaders,
735
- allowMethods,
736
- maxAgeSeconds,
902
+ allowCredentials: corsConfig.allowCredentials,
903
+ allowHeaders: corsConfig.allowHeaders,
904
+ exposeHeaders: corsConfig.exposeHeaders,
905
+ allowMethods: corsConfig.allowMethods,
906
+ maxAgeSeconds: corsConfig.maxAgeSeconds,
737
907
  });
738
908
  return c.res;
739
909
  });
@@ -58,6 +58,35 @@ function isAllowedOriginMatch(origin: URL, pattern: string): boolean {
58
58
  return matchesWildcardOriginPattern(origin, pattern);
59
59
  }
60
60
 
61
+ export function resolveAllowedOriginFromPatterns(
62
+ originHeader: string | null | undefined,
63
+ allowedOrigins: string | string[] | '*'
64
+ ): string | null {
65
+ if (allowedOrigins === '*') {
66
+ return '*';
67
+ }
68
+
69
+ const normalizedAllowedOrigins =
70
+ typeof allowedOrigins === 'string' ? [allowedOrigins] : allowedOrigins;
71
+
72
+ if (!originHeader) {
73
+ return null;
74
+ }
75
+
76
+ let parsedOrigin: URL;
77
+ try {
78
+ parsedOrigin = new URL(originHeader);
79
+ } catch {
80
+ return null;
81
+ }
82
+
83
+ return normalizedAllowedOrigins.some((pattern) =>
84
+ isAllowedOriginMatch(parsedOrigin, pattern)
85
+ )
86
+ ? originHeader
87
+ : null;
88
+ }
89
+
61
90
  export function isRequestOriginAllowed(args: {
62
91
  requestUrl: string;
63
92
  originHeader?: string | null;
@@ -69,15 +98,8 @@ export function isRequestOriginAllowed(args: {
69
98
 
70
99
  const origin = args.originHeader;
71
100
  if (Array.isArray(args.allowedOrigins)) {
72
- if (!origin) return false;
73
- let parsedOrigin: URL;
74
- try {
75
- parsedOrigin = new URL(origin);
76
- } catch {
77
- return false;
78
- }
79
- return args.allowedOrigins.some((pattern) =>
80
- isAllowedOriginMatch(parsedOrigin, pattern)
101
+ return (
102
+ resolveAllowedOriginFromPatterns(origin, args.allowedOrigins) !== null
81
103
  );
82
104
  }
83
105