@specific.dev/spectest 0.98.0 → 0.100.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.
@@ -42,6 +42,7 @@
42
42
  * yet.
43
43
  */
44
44
 
45
+ import type { CorsMode } from "../ingress.js";
45
46
  import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
46
47
 
47
48
  /**
@@ -53,7 +54,7 @@ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
53
54
  */
54
55
  export type Route<F = unknown> =
55
56
  | { kind: "fake"; fake: F }
56
- | { kind: "proxy"; service: string; port: number; http2?: boolean };
57
+ | { kind: "proxy"; service: string; port: number; http2?: boolean; cors?: CorsMode };
57
58
 
58
59
  /** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
59
60
  export interface Leaf {
package/src/index.ts CHANGED
@@ -190,6 +190,7 @@ export {
190
190
  SELF_SERVICE_TOKEN,
191
191
  } from "./ingress.js";
192
192
  export type {
193
+ CorsMode,
193
194
  CertificateDecl,
194
195
  DnsDecl,
195
196
  ProxyDecl,
@@ -197,7 +198,7 @@ export type {
197
198
  DnsTarget,
198
199
  LoweredIngress,
199
200
  } from "./ingress.js";
200
- import type { DnsTarget } from "./ingress.js";
201
+ import { validateCorsMode, type CorsMode, type DnsTarget } from "./ingress.js";
201
202
  import type {
202
203
  InterceptHandler,
203
204
  InterceptNext,
@@ -1164,6 +1165,9 @@ export interface ServiceTls {
1164
1165
  /** Accept HTTP/2 over TLS and forward it as plaintext HTTP/2 (h2c).
1165
1166
  * Required for gRPC. HTTP/1.1 continues to use the existing HTTP proxy. */
1166
1167
  http2?: boolean;
1168
+ /** Preserve upstream CORS policy by default. "permissive" opts HTTP/1
1169
+ * ingress into answering preflights and adding credentialed CORS headers. */
1170
+ cors?: CorsMode;
1167
1171
  }
1168
1172
 
1169
1173
  /** @internal Normalized wire representation; legacy definitions remain accepted. */
@@ -1665,6 +1669,7 @@ function validateEnvironmentConfig<S extends ServicesMap>(
1665
1669
  );
1666
1670
  }
1667
1671
  if (entry.http2 !== undefined && typeof entry.http2 !== "boolean") throw new Error(`service "${name}" tls http2 must be a boolean`);
1672
+ validateCorsMode(entry.cors);
1668
1673
  const h = entry.hostname.toLowerCase();
1669
1674
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
1670
1675
  throw new Error(
@@ -2144,6 +2149,9 @@ export interface FakeDefinition<
2144
2149
  hostnames: readonly string[];
2145
2150
  /** TCP port the fake listens on. Default `80`. */
2146
2151
  port?: number;
2152
+ /** Preserve handler/interceptor CORS policy by default. "permissive"
2153
+ * answers preflights and adds credentialed CORS headers at ingress. */
2154
+ cors?: CorsMode;
2147
2155
  /**
2148
2156
  * Build the fake's initial state. Called once when the project loads.
2149
2157
  * The returned value lives in daemon memory for the rest of the
@@ -2227,6 +2235,7 @@ export function defineFake<
2227
2235
  );
2228
2236
  }
2229
2237
  }
2238
+ validateCorsMode(opts.cors);
2230
2239
  if (typeof opts.handler !== "function") {
2231
2240
  throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
2232
2241
  }
package/src/ingress.ts CHANGED
@@ -25,6 +25,17 @@ import type { FakeDefinition, Project, ServiceConfig } from "./index.js";
25
25
  * live IP on spectest-net (a plain peer alias, no daemon hop). */
26
26
  export type DnsTarget = { ingress: true } | { service: string };
27
27
 
28
+ /** Passthrough preserves application CORS policy (the default). Permissive
29
+ * answers preflights and adds credentialed CORS headers at HTTP/1 ingress. */
30
+ export type CorsMode = "passthrough" | "permissive";
31
+
32
+ /** @internal Validate JavaScript config as well as TypeScript callers. */
33
+ export function validateCorsMode(cors: unknown): void {
34
+ if (cors !== undefined && cors !== "passthrough" && cors !== "permissive") {
35
+ throw new Error('cors must be "passthrough" or "permissive"');
36
+ }
37
+ }
38
+
28
39
  export interface CertificateDecl {
29
40
  kind: "certificate";
30
41
  /** Hostnames the leaf cert's SANs cover. One cert is minted per decl. */
@@ -40,7 +51,7 @@ export interface DnsDecl {
40
51
  export interface ProxyDecl {
41
52
  kind: "proxy";
42
53
  hostname: string;
43
- upstream: { service: string; port: number; http2?: boolean };
54
+ upstream: { service: string; port: number; http2?: boolean; cors?: CorsMode };
44
55
  }
45
56
 
46
57
  export type IngressDecl = CertificateDecl | DnsDecl | ProxyDecl;
@@ -137,7 +148,7 @@ export function dnsName(hostname: string, target: DnsTarget): DnsDecl {
137
148
  */
138
149
  export function proxy(
139
150
  hostname: string,
140
- upstream: { service: string; port: number; http2?: boolean },
151
+ upstream: { service: string; port: number; http2?: boolean; cors?: CorsMode },
141
152
  ): ProxyDecl {
142
153
  assertNameOrWildcard(hostname, "proxy()");
143
154
  if (!upstream || typeof upstream.service !== "string" || typeof upstream.port !== "number") {
@@ -146,6 +157,7 @@ export function proxy(
146
157
  );
147
158
  }
148
159
  if (upstream.http2 !== undefined && typeof upstream.http2 !== "boolean") throw new Error("proxy(): http2 must be a boolean");
160
+ validateCorsMode(upstream.cors);
149
161
  return { kind: "proxy", hostname: hostname.toLowerCase(), upstream };
150
162
  }
151
163
 
@@ -198,7 +210,7 @@ export interface LoweredIngress {
198
210
  /** One leaf cert per group; SANs = the group's hostnames. */
199
211
  certificates: { hostnames: string[] }[];
200
212
  /** Reverse-proxy routes: hostname → upstream service:port. */
201
- proxies: { hostname: string; service: string; port: number; http2?: boolean }[];
213
+ proxies: { hostname: string; service: string; port: number; http2?: boolean; cors?: CorsMode }[];
202
214
  /** Hostnames that resolve to the daemon ingress gateway (DNS registry
203
215
  * + `--add-host` on every container). Covers fakes, TLS proxies, and
204
216
  * any `dnsName(h, { ingress: true })`. */
@@ -227,7 +239,7 @@ function resolveSelf(service: string, selfKey: string): string {
227
239
  */
228
240
  export function lowerIngress(project: Project): LoweredIngress {
229
241
  const certificates: { hostnames: string[] }[] = [];
230
- const proxies: { hostname: string; service: string; port: number; http2?: boolean }[] = [];
242
+ const proxies: { hostname: string; service: string; port: number; http2?: boolean; cors?: CorsMode }[] = [];
231
243
  const ingressSet = new Set<string>();
232
244
  const aliasesByService: Record<string, string[]> = {};
233
245
  const wildcards: { pattern: string; target: DnsTarget }[] = [];
@@ -242,7 +254,7 @@ export function lowerIngress(project: Project): LoweredIngress {
242
254
  break;
243
255
  case "proxy": {
244
256
  const service = resolveSelf(decl.upstream.service, selfKey);
245
- proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}) });
257
+ proxies.push({ hostname: decl.hostname, service, port: decl.upstream.port, ...(decl.upstream.http2 ? { http2: true } : {}), ...(decl.upstream.cors ? { cors: decl.upstream.cors } : {}) });
246
258
  // A proxied hostname must route to the daemon. A wildcard one can
247
259
  // only be answered by the resolver (there is no `--add-host` for a
248
260
  // pattern), so it goes in the wildcard table pointed at the ingress.
@@ -279,7 +291,7 @@ export function lowerIngress(project: Project): LoweredIngress {
279
291
  // `tls: [{ hostname, port }]` → cert + TLS-terminated reverse proxy.
280
292
  for (const entry of (svc as ServiceConfig).tls ?? []) {
281
293
  applyDecl(certificate([entry.hostname]), name);
282
- applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}) }), name);
294
+ applyDecl(proxy(entry.hostname, { service: name, port: entry.port, ...(entry.http2 ? { http2: true } : {}), ...(entry.cors ? { cors: entry.cors } : {}) }), name);
283
295
  }
284
296
  // `hostnames: [h]` → plain peer alias (no daemon hop).
285
297
  for (const h of (svc as ServiceConfig).hostnames ?? []) {