@chidchanun/bcp 0.2.13 → 0.2.15

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.
@@ -0,0 +1,2357 @@
1
+ // packages/server/src/testing.ts
2
+ import {
3
+ randomUUID
4
+ } from "node:crypto";
5
+ import {
6
+ isDeepStrictEqual
7
+ } from "node:util";
8
+
9
+ // packages/server/src/security.ts
10
+ var DEFAULT_BODY_LIMIT = 1024 * 1024;
11
+ function assertSafeMiddlewareTarget(target, originalUrl, kind) {
12
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
13
+ throw new Error(
14
+ `BCP Framework: middleware ${kind} only supports http: and https: URLs.`
15
+ );
16
+ }
17
+ if (target.username || target.password) {
18
+ throw new Error(
19
+ `BCP Framework: middleware ${kind} URLs must not contain credentials.`
20
+ );
21
+ }
22
+ if (kind === "rewrite" && target.origin !== originalUrl.origin) {
23
+ throw new Error(
24
+ "BCP Framework: middleware rewrites must stay on the same origin."
25
+ );
26
+ }
27
+ }
28
+
29
+ // packages/server/src/middleware.ts
30
+ var middlewareRedirectTargets = /* @__PURE__ */ new WeakMap();
31
+ async function executeMiddlewarePipeline(module, input, downstream) {
32
+ const initialUrl = input.url instanceof URL ? new URL(
33
+ input.url.href
34
+ ) : new URL(
35
+ input.url
36
+ );
37
+ const method = input.method ?? "GET";
38
+ const initialHeaders = new Headers(
39
+ input.headers
40
+ );
41
+ if (!module || isFrameworkInternalPath(
42
+ initialUrl.pathname
43
+ ) || !matchesMiddleware(
44
+ initialUrl.pathname,
45
+ module.config?.matcher
46
+ )) {
47
+ return downstream({
48
+ url: initialUrl,
49
+ method,
50
+ headers: initialHeaders
51
+ });
52
+ }
53
+ const handlers = getMiddlewareHandlers(
54
+ module
55
+ );
56
+ if (handlers.length === 0) {
57
+ return downstream({
58
+ url: initialUrl,
59
+ method,
60
+ headers: initialHeaders
61
+ });
62
+ }
63
+ const current = {
64
+ url: initialUrl,
65
+ headers: initialHeaders
66
+ };
67
+ const context = {
68
+ state: /* @__PURE__ */ Object.create(
69
+ null
70
+ )
71
+ };
72
+ const dispatch = async (index) => {
73
+ if (index >= handlers.length) {
74
+ return downstream({
75
+ url: new URL(
76
+ current.url.href
77
+ ),
78
+ method,
79
+ headers: new Headers(
80
+ current.headers
81
+ )
82
+ });
83
+ }
84
+ const handler = handlers[index];
85
+ const request = createMiddlewareRequest(
86
+ current.url,
87
+ method,
88
+ current.headers
89
+ );
90
+ let nextPromise = null;
91
+ const runNext = () => {
92
+ if (nextPromise) {
93
+ throw new Error(
94
+ "BCP Framework: middleware next() may only be called once per handler."
95
+ );
96
+ }
97
+ nextPromise = dispatch(
98
+ index + 1
99
+ );
100
+ return nextPromise;
101
+ };
102
+ const invoke = handler;
103
+ const result = await invoke(
104
+ request,
105
+ runNext,
106
+ context
107
+ );
108
+ if (result === void 0) {
109
+ return nextPromise ? await nextPromise : runNext();
110
+ }
111
+ if (result instanceof Response) {
112
+ return result;
113
+ }
114
+ if (result.type === "redirect") {
115
+ const target = new URL(
116
+ result.url,
117
+ current.url
118
+ );
119
+ assertSafeMiddlewareTarget(
120
+ target,
121
+ current.url,
122
+ "redirect"
123
+ );
124
+ const headers2 = new Headers(
125
+ result.headers
126
+ );
127
+ headers2.set(
128
+ "Location",
129
+ target.href
130
+ );
131
+ const response2 = new Response(
132
+ null,
133
+ {
134
+ status: result.status,
135
+ headers: headers2
136
+ }
137
+ );
138
+ middlewareRedirectTargets.set(
139
+ response2,
140
+ target
141
+ );
142
+ return response2;
143
+ }
144
+ if (result.type === "rewrite") {
145
+ if (nextPromise) {
146
+ throw new Error(
147
+ "BCP Framework: rewrite() must be returned before middleware calls next()."
148
+ );
149
+ }
150
+ const target = new URL(
151
+ result.url,
152
+ current.url
153
+ );
154
+ assertSafeMiddlewareTarget(
155
+ target,
156
+ current.url,
157
+ "rewrite"
158
+ );
159
+ current.url = target;
160
+ current.headers = mergeHeaders(
161
+ current.headers,
162
+ result.requestHeaders
163
+ );
164
+ const response2 = await runNext();
165
+ return applyHeadersToResponse(
166
+ response2,
167
+ result.responseHeaders
168
+ );
169
+ }
170
+ current.headers = mergeHeaders(
171
+ current.headers,
172
+ result.requestHeaders
173
+ );
174
+ const response = nextPromise ? await nextPromise : await runNext();
175
+ return applyHeadersToResponse(
176
+ response,
177
+ result.responseHeaders
178
+ );
179
+ };
180
+ return dispatch(0);
181
+ }
182
+ function matchesMiddleware(pathname, matcher) {
183
+ if (matcher === void 0) {
184
+ return true;
185
+ }
186
+ const matchers = Array.isArray(
187
+ matcher
188
+ ) ? matcher : [
189
+ matcher
190
+ ];
191
+ if (matchers.length === 0) {
192
+ return false;
193
+ }
194
+ return matchers.some(
195
+ (value) => {
196
+ if (value instanceof RegExp) {
197
+ value.lastIndex = 0;
198
+ return value.test(
199
+ pathname
200
+ );
201
+ }
202
+ return matchPathPattern(
203
+ pathname,
204
+ value
205
+ );
206
+ }
207
+ );
208
+ }
209
+ function getMiddlewareHandlers(module) {
210
+ const value = module.middleware ?? module.default;
211
+ if (!value) {
212
+ return [];
213
+ }
214
+ return Array.isArray(
215
+ value
216
+ ) ? [
217
+ ...value
218
+ ] : [
219
+ value
220
+ ];
221
+ }
222
+ function createMiddlewareRequest(url, method, headers2) {
223
+ const requestUrl = new URL(
224
+ url.href
225
+ );
226
+ const requestHeaders = new Headers(
227
+ headers2
228
+ );
229
+ return {
230
+ url: requestUrl.href,
231
+ method,
232
+ headers: requestHeaders,
233
+ nextUrl: requestUrl,
234
+ cookies: createCookieStore(
235
+ requestHeaders.get(
236
+ "cookie"
237
+ )
238
+ )
239
+ };
240
+ }
241
+ function applyHeadersToResponse(response, additions) {
242
+ let hasAdditions = false;
243
+ additions.forEach(
244
+ () => {
245
+ hasAdditions = true;
246
+ }
247
+ );
248
+ if (!hasAdditions) {
249
+ return response;
250
+ }
251
+ const headers2 = new Headers(
252
+ response.headers
253
+ );
254
+ additions.forEach(
255
+ (value, key) => {
256
+ headers2.set(
257
+ key,
258
+ value
259
+ );
260
+ }
261
+ );
262
+ const wrapped = new Response(
263
+ response.body,
264
+ {
265
+ status: response.status,
266
+ statusText: response.statusText,
267
+ headers: headers2
268
+ }
269
+ );
270
+ const redirectTarget = middlewareRedirectTargets.get(
271
+ response
272
+ );
273
+ if (redirectTarget) {
274
+ middlewareRedirectTargets.set(
275
+ wrapped,
276
+ redirectTarget
277
+ );
278
+ }
279
+ return wrapped;
280
+ }
281
+ function matchPathPattern(pathname, pattern) {
282
+ if (pattern === "*" || pattern === "/:path*") {
283
+ return true;
284
+ }
285
+ if (!pattern.startsWith(
286
+ "/"
287
+ )) {
288
+ throw new Error(
289
+ `BCP Framework: middleware matcher "${pattern}" must start with "/".`
290
+ );
291
+ }
292
+ const escaped = pattern.split("/").filter(
293
+ Boolean
294
+ ).map(
295
+ (segment) => {
296
+ if (segment === "*" || /^:[A-Za-z_][A-Za-z0-9_]*\*$/.test(
297
+ segment
298
+ )) {
299
+ return "(?:.*)?";
300
+ }
301
+ if (/^:[A-Za-z_][A-Za-z0-9_]*$/.test(
302
+ segment
303
+ )) {
304
+ return "[^/]+";
305
+ }
306
+ return escapeRegExp(
307
+ segment
308
+ );
309
+ }
310
+ );
311
+ if (escaped.length === 0) {
312
+ return pathname === "/";
313
+ }
314
+ const source = `^/${escaped.join("/")}/?$`;
315
+ return new RegExp(
316
+ source
317
+ ).test(
318
+ pathname
319
+ );
320
+ }
321
+ function createCookieStore(cookieHeader) {
322
+ const values = /* @__PURE__ */ new Map();
323
+ if (cookieHeader) {
324
+ for (const part of cookieHeader.split(";")) {
325
+ const separator = part.indexOf("=");
326
+ if (separator < 0) {
327
+ continue;
328
+ }
329
+ const name = part.slice(
330
+ 0,
331
+ separator
332
+ ).trim();
333
+ const rawValue = part.slice(
334
+ separator + 1
335
+ ).trim();
336
+ if (!name) {
337
+ continue;
338
+ }
339
+ try {
340
+ values.set(
341
+ name,
342
+ decodeURIComponent(
343
+ rawValue
344
+ )
345
+ );
346
+ } catch {
347
+ values.set(
348
+ name,
349
+ rawValue
350
+ );
351
+ }
352
+ }
353
+ }
354
+ return {
355
+ get(name) {
356
+ return values.get(
357
+ name
358
+ ) ?? null;
359
+ },
360
+ has(name) {
361
+ return values.has(
362
+ name
363
+ );
364
+ }
365
+ };
366
+ }
367
+ function mergeHeaders(base, additions) {
368
+ const result = new Headers(
369
+ base
370
+ );
371
+ additions.forEach(
372
+ (value, key) => {
373
+ result.set(
374
+ key,
375
+ value
376
+ );
377
+ }
378
+ );
379
+ return result;
380
+ }
381
+ function isFrameworkInternalPath(pathname) {
382
+ return pathname === "/_bcp" || pathname.startsWith(
383
+ "/_bcp/"
384
+ );
385
+ }
386
+ function escapeRegExp(value) {
387
+ return value.replace(
388
+ /[.*+?^${}()|[\]\\]/g,
389
+ "\\$&"
390
+ );
391
+ }
392
+
393
+ // packages/server/src/session.ts
394
+ import {
395
+ createHmac,
396
+ timingSafeEqual
397
+ } from "node:crypto";
398
+
399
+ // packages/server/src/request-context.ts
400
+ import {
401
+ AsyncLocalStorage
402
+ } from "node:async_hooks";
403
+ var requestStorage = new AsyncLocalStorage();
404
+ async function headers() {
405
+ const context = getRequestContext();
406
+ return new Headers(
407
+ context.headers
408
+ );
409
+ }
410
+ function getRequestContext() {
411
+ const context = requestStorage.getStore();
412
+ if (!context) {
413
+ throw new Error(
414
+ "BCP Framework: server request APIs can only be used while handling a request."
415
+ );
416
+ }
417
+ return context;
418
+ }
419
+
420
+ // packages/server/src/session.ts
421
+ var DEFAULT_EXPIRES_IN = 60 * 60 * 12;
422
+ var MAX_TOKEN_LENGTH = 16 * 1024;
423
+ var MINIMUM_SECRET_BYTES = 32;
424
+ async function createSessionToken(payload, options = {}) {
425
+ assertPayload(
426
+ payload
427
+ );
428
+ const secret = resolveSessionSecret(
429
+ options.secret
430
+ );
431
+ const expiresIn = resolveExpiresIn(
432
+ options.expiresIn
433
+ );
434
+ const issuer = resolveOptionalIssuer(
435
+ options.issuer
436
+ );
437
+ const audience = options.audience === void 0 ? void 0 : normalizeAudience(
438
+ options.audience
439
+ );
440
+ const now = Math.floor(
441
+ Date.now() / 1e3
442
+ );
443
+ const header = {
444
+ alg: "HS256",
445
+ typ: "JWT"
446
+ };
447
+ const claims = {
448
+ ...payload,
449
+ iat: now,
450
+ exp: now + expiresIn
451
+ };
452
+ if (issuer !== void 0) {
453
+ claims.iss = issuer;
454
+ }
455
+ if (audience !== void 0) {
456
+ claims.aud = audience;
457
+ }
458
+ const encodedHeader = encodeJson(
459
+ header
460
+ );
461
+ const encodedPayload = encodeJson(
462
+ claims
463
+ );
464
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
465
+ const signature = signHs256(
466
+ signingInput,
467
+ secret
468
+ );
469
+ return `${signingInput}.${signature}`;
470
+ }
471
+ function resolveSessionSecret(explicitSecret) {
472
+ const secret = explicitSecret ?? process.env.BCP_SESSION_SECRET;
473
+ if (!secret) {
474
+ throw new Error(
475
+ "BCP Framework: BCP_SESSION_SECRET is required for session tokens."
476
+ );
477
+ }
478
+ if (Buffer.byteLength(
479
+ secret,
480
+ "utf8"
481
+ ) < MINIMUM_SECRET_BYTES) {
482
+ throw new Error(
483
+ `BCP Framework: session secret must be at least ${MINIMUM_SECRET_BYTES} bytes.`
484
+ );
485
+ }
486
+ return secret;
487
+ }
488
+ function resolveExpiresIn(value) {
489
+ const expiresIn = value ?? DEFAULT_EXPIRES_IN;
490
+ if (!Number.isFinite(
491
+ expiresIn
492
+ ) || expiresIn <= 0) {
493
+ throw new Error(
494
+ "BCP Framework: session expiresIn must be a positive finite number of seconds."
495
+ );
496
+ }
497
+ return Math.floor(
498
+ expiresIn
499
+ );
500
+ }
501
+ function resolveOptionalIssuer(value) {
502
+ if (value === void 0) {
503
+ return void 0;
504
+ }
505
+ assertNonEmptyString(
506
+ value,
507
+ "issuer"
508
+ );
509
+ return value;
510
+ }
511
+ function assertPayload(payload) {
512
+ if (payload === null || typeof payload !== "object" || Array.isArray(
513
+ payload
514
+ )) {
515
+ throw new Error(
516
+ "BCP Framework: session payload must be a plain object."
517
+ );
518
+ }
519
+ }
520
+ function assertNonEmptyString(value, label) {
521
+ if (typeof value !== "string" || value.trim().length === 0) {
522
+ throw new Error(
523
+ `BCP Framework: session ${label} must be a non-empty string.`
524
+ );
525
+ }
526
+ }
527
+ function normalizeAudience(value) {
528
+ if (typeof value === "string") {
529
+ assertNonEmptyString(
530
+ value,
531
+ "audience"
532
+ );
533
+ return value;
534
+ }
535
+ if (!Array.isArray(
536
+ value
537
+ ) || value.length === 0) {
538
+ throw new Error(
539
+ "BCP Framework: session audience must contain at least one value."
540
+ );
541
+ }
542
+ for (const audience of value) {
543
+ assertNonEmptyString(
544
+ audience,
545
+ "audience"
546
+ );
547
+ }
548
+ return [
549
+ ...value
550
+ ];
551
+ }
552
+ function encodeJson(value) {
553
+ return Buffer.from(
554
+ JSON.stringify(
555
+ value
556
+ ),
557
+ "utf8"
558
+ ).toString(
559
+ "base64url"
560
+ );
561
+ }
562
+ function signHs256(signingInput, secret) {
563
+ return createHmac(
564
+ "sha256",
565
+ secret
566
+ ).update(
567
+ signingInput,
568
+ "utf8"
569
+ ).digest(
570
+ "base64url"
571
+ );
572
+ }
573
+
574
+ // packages/server/src/testing.ts
575
+ function createTestApp(options) {
576
+ if (!options || typeof options.handler !== "function") {
577
+ throw new TypeError(
578
+ "BCP Testing: createTestApp requires a request handler."
579
+ );
580
+ }
581
+ const baseUrl = normalizeBaseUrl(
582
+ options.baseUrl ?? "http://bcp.test"
583
+ );
584
+ const initialHeaders = new Headers(
585
+ options.headers
586
+ );
587
+ const defaultHeaders = new Headers(
588
+ initialHeaders
589
+ );
590
+ const initialCookies = {
591
+ ...options.cookies ?? {}
592
+ };
593
+ const cookieJar = new Map(
594
+ Object.entries(initialCookies)
595
+ );
596
+ const app = {
597
+ baseUrl,
598
+ async request(path, requestOptions = {}) {
599
+ const method = normalizeMethod(
600
+ requestOptions.method ?? "GET"
601
+ );
602
+ const headers2 = new Headers(
603
+ defaultHeaders
604
+ );
605
+ for (const [name, value] of new Headers(
606
+ requestOptions.headers
607
+ )) {
608
+ headers2.set(name, value);
609
+ }
610
+ const requestCookies = new Map(cookieJar);
611
+ for (const [name, value] of Object.entries(
612
+ requestOptions.cookies ?? {}
613
+ )) {
614
+ requestCookies.set(name, value);
615
+ }
616
+ if (requestCookies.size > 0) {
617
+ headers2.set(
618
+ "Cookie",
619
+ serializeCookies(
620
+ requestCookies
621
+ )
622
+ );
623
+ }
624
+ let body = requestOptions.body ?? null;
625
+ if (requestOptions.json !== void 0) {
626
+ if (requestOptions.body !== void 0 && requestOptions.body !== null) {
627
+ throw new TypeError(
628
+ "BCP Testing: request cannot specify both body and json."
629
+ );
630
+ }
631
+ body = JSON.stringify(
632
+ requestOptions.json
633
+ );
634
+ if (!headers2.has("Content-Type")) {
635
+ headers2.set(
636
+ "Content-Type",
637
+ "application/json"
638
+ );
639
+ }
640
+ }
641
+ if (method === "GET" || method === "HEAD") {
642
+ body = null;
643
+ }
644
+ const request = new Request(
645
+ resolveTestUrl(
646
+ baseUrl,
647
+ path
648
+ ),
649
+ {
650
+ method,
651
+ headers: headers2,
652
+ body,
653
+ signal: requestOptions.signal
654
+ }
655
+ );
656
+ const response = await options.handler(
657
+ request
658
+ );
659
+ if (!(response instanceof Response)) {
660
+ throw new TypeError(
661
+ "BCP Testing: request handler must return a Response."
662
+ );
663
+ }
664
+ updateCookieJar(
665
+ cookieJar,
666
+ response.headers
667
+ );
668
+ return response;
669
+ },
670
+ get(path, requestOptions = {}) {
671
+ return app.request(
672
+ path,
673
+ {
674
+ ...requestOptions,
675
+ method: "GET"
676
+ }
677
+ );
678
+ },
679
+ post(path, requestOptions = {}) {
680
+ return app.request(
681
+ path,
682
+ {
683
+ ...requestOptions,
684
+ method: "POST"
685
+ }
686
+ );
687
+ },
688
+ put(path, requestOptions = {}) {
689
+ return app.request(
690
+ path,
691
+ {
692
+ ...requestOptions,
693
+ method: "PUT"
694
+ }
695
+ );
696
+ },
697
+ patch(path, requestOptions = {}) {
698
+ return app.request(
699
+ path,
700
+ {
701
+ ...requestOptions,
702
+ method: "PATCH"
703
+ }
704
+ );
705
+ },
706
+ delete(path, requestOptions = {}) {
707
+ return app.request(
708
+ path,
709
+ {
710
+ ...requestOptions,
711
+ method: "DELETE"
712
+ }
713
+ );
714
+ },
715
+ setCookie(name, value) {
716
+ cookieJar.set(
717
+ normalizeCookieName(name),
718
+ String(value)
719
+ );
720
+ },
721
+ clearCookie(name) {
722
+ cookieJar.delete(
723
+ normalizeCookieName(name)
724
+ );
725
+ },
726
+ cookies() {
727
+ return Object.fromEntries(
728
+ cookieJar
729
+ );
730
+ },
731
+ setHeader(name, value) {
732
+ defaultHeaders.set(
733
+ normalizeHeaderName(name),
734
+ String(value)
735
+ );
736
+ },
737
+ deleteHeader(name) {
738
+ defaultHeaders.delete(
739
+ normalizeHeaderName(name)
740
+ );
741
+ },
742
+ headers() {
743
+ return new Headers(
744
+ defaultHeaders
745
+ );
746
+ },
747
+ reset() {
748
+ cookieJar.clear();
749
+ for (const [name, value] of Object.entries(
750
+ initialCookies
751
+ )) {
752
+ cookieJar.set(name, value);
753
+ }
754
+ for (const name of [...defaultHeaders.keys()]) {
755
+ defaultHeaders.delete(name);
756
+ }
757
+ for (const [name, value] of initialHeaders) {
758
+ defaultHeaders.set(name, value);
759
+ }
760
+ }
761
+ };
762
+ return app;
763
+ }
764
+ function createRouteTestHandler(routeModule, options = {}) {
765
+ if (!routeModule || typeof routeModule !== "object") {
766
+ throw new TypeError(
767
+ "BCP Testing: route module must be an object."
768
+ );
769
+ }
770
+ return async (request) => {
771
+ const method = normalizeMethod(
772
+ request.method
773
+ );
774
+ const handler = routeModule[method] ?? (method === "HEAD" ? routeModule.GET : void 0);
775
+ if (!handler) {
776
+ const allow = knownRouteMethods.filter(
777
+ (candidate) => typeof routeModule[candidate] === "function"
778
+ ).join(", ");
779
+ return new Response(
780
+ null,
781
+ {
782
+ status: 405,
783
+ headers: allow ? {
784
+ Allow: allow
785
+ } : void 0
786
+ }
787
+ );
788
+ }
789
+ const context = options.context ? await options.context(
790
+ request
791
+ ) : void 0;
792
+ const value = await handler(
793
+ request,
794
+ context
795
+ );
796
+ return normalizeRouteResponse(
797
+ value,
798
+ method
799
+ );
800
+ };
801
+ }
802
+ function expectResponse(response) {
803
+ if (!(response instanceof Response)) {
804
+ throw new TypeError(
805
+ "BCP Testing: expectResponse requires a Response."
806
+ );
807
+ }
808
+ const expectation = {
809
+ response,
810
+ status(expected) {
811
+ if (response.status !== expected) {
812
+ throw new Error(
813
+ `BCP Testing: expected response status ${expected}, received ${response.status}.`
814
+ );
815
+ }
816
+ return expectation;
817
+ },
818
+ header(name, expected) {
819
+ const actual = response.headers.get(name);
820
+ if (typeof expected === "string" ? actual !== expected : !expected.test(
821
+ actual ?? ""
822
+ )) {
823
+ throw new Error(
824
+ `BCP Testing: response header "${name}" did not match. Received ${JSON.stringify(actual)}.`
825
+ );
826
+ }
827
+ return expectation;
828
+ },
829
+ async text(expected) {
830
+ const actual = await response.clone().text();
831
+ if (typeof expected === "string" ? actual !== expected : !expected.test(actual)) {
832
+ throw new Error(
833
+ `BCP Testing: response text did not match. Received ${JSON.stringify(actual)}.`
834
+ );
835
+ }
836
+ return expectation;
837
+ },
838
+ async json(expected) {
839
+ const actual = await response.clone().json();
840
+ if (!isDeepStrictEqual(actual, expected)) {
841
+ throw new Error(
842
+ `BCP Testing: response JSON did not match. Expected ${safeJson(expected)}, received ${safeJson(actual)}.`
843
+ );
844
+ }
845
+ return expectation;
846
+ },
847
+ async jsonMatches(expected) {
848
+ const actual = await response.clone().json();
849
+ if (!actual || typeof actual !== "object" || Array.isArray(actual)) {
850
+ throw new Error(
851
+ "BCP Testing: response JSON is not an object."
852
+ );
853
+ }
854
+ for (const [key, value] of Object.entries(expected)) {
855
+ if (!isDeepStrictEqual(
856
+ actual[key],
857
+ value
858
+ )) {
859
+ throw new Error(
860
+ `BCP Testing: response JSON key "${key}" did not match.`
861
+ );
862
+ }
863
+ }
864
+ return expectation;
865
+ }
866
+ };
867
+ return expectation;
868
+ }
869
+ function createFakeClock(initialValue = 0) {
870
+ assertFinite(
871
+ initialValue,
872
+ "fake clock initial value"
873
+ );
874
+ let current = initialValue;
875
+ return {
876
+ now() {
877
+ return current;
878
+ },
879
+ value() {
880
+ return current;
881
+ },
882
+ set(value) {
883
+ assertFinite(
884
+ value,
885
+ "fake clock value"
886
+ );
887
+ current = value;
888
+ return current;
889
+ },
890
+ advance(milliseconds) {
891
+ assertFinite(
892
+ milliseconds,
893
+ "fake clock advance"
894
+ );
895
+ current += milliseconds;
896
+ return current;
897
+ },
898
+ reset() {
899
+ current = initialValue;
900
+ return current;
901
+ }
902
+ };
903
+ }
904
+ function createSequenceIdFactory(prefix = "test", start = 1) {
905
+ const normalizedPrefix = String(prefix).trim();
906
+ if (!normalizedPrefix) {
907
+ throw new TypeError(
908
+ "BCP Testing: id prefix must be a non-empty string."
909
+ );
910
+ }
911
+ if (!Number.isInteger(start) || start < 0) {
912
+ throw new TypeError(
913
+ "BCP Testing: id sequence start must be a non-negative integer."
914
+ );
915
+ }
916
+ let sequence = start;
917
+ return () => `${normalizedPrefix}-${sequence++}`;
918
+ }
919
+ async function createTestAuthSession(user, options = {}) {
920
+ if (!user || typeof user.id !== "string" && typeof user.id !== "number") {
921
+ throw new TypeError(
922
+ "BCP Testing: auth user requires a string or number id."
923
+ );
924
+ }
925
+ const idFactory = options.idFactory ?? randomUUID;
926
+ const sid = normalizeNonEmpty(
927
+ options.sid ?? idFactory(),
928
+ "session id"
929
+ );
930
+ const cookieName = normalizeCookieName(
931
+ options.cookieName ?? "bcp_session"
932
+ );
933
+ const expiresIn = positiveInteger(
934
+ options.expiresIn ?? 60 * 60 * 12,
935
+ "session expiresIn"
936
+ );
937
+ const createdAt = Math.floor(
938
+ Date.now() / 1e3
939
+ );
940
+ const expiresAt = createdAt + expiresIn;
941
+ const payload = {
942
+ sid,
943
+ user,
944
+ ...options.data === void 0 ? {} : {
945
+ data: options.data
946
+ }
947
+ };
948
+ const token = await createSessionToken(
949
+ payload,
950
+ {
951
+ secret: options.secret,
952
+ expiresIn,
953
+ issuer: options.issuer,
954
+ audience: options.audience
955
+ }
956
+ );
957
+ if (options.store) {
958
+ await options.store.set({
959
+ sid,
960
+ userId: String(user.id),
961
+ createdAt,
962
+ expiresAt,
963
+ lastSeenAt: createdAt
964
+ });
965
+ }
966
+ return {
967
+ sid,
968
+ user,
969
+ ...options.data === void 0 ? {} : {
970
+ data: options.data
971
+ },
972
+ token,
973
+ cookieName,
974
+ cookieHeader: `${cookieName}=${token}`,
975
+ createdAt,
976
+ expiresAt
977
+ };
978
+ }
979
+ async function withTestTransaction(database, callback) {
980
+ if (!database || typeof database.transaction !== "function") {
981
+ throw new TypeError(
982
+ "BCP Testing: withTestTransaction requires a transactional database."
983
+ );
984
+ }
985
+ if (typeof callback !== "function") {
986
+ throw new TypeError(
987
+ "BCP Testing: transaction callback must be a function."
988
+ );
989
+ }
990
+ const marker = /* @__PURE__ */ Symbol("bcp-test-rollback");
991
+ let completed = false;
992
+ let value;
993
+ try {
994
+ await database.transaction(
995
+ async (transaction) => {
996
+ value = await callback(
997
+ transaction
998
+ );
999
+ completed = true;
1000
+ throw new TestRollbackSignal(
1001
+ marker
1002
+ );
1003
+ }
1004
+ );
1005
+ } catch (error) {
1006
+ if (!(error instanceof TestRollbackSignal) || error.marker !== marker) {
1007
+ throw error;
1008
+ }
1009
+ }
1010
+ if (!completed) {
1011
+ throw new Error(
1012
+ "BCP Testing: rollback transaction did not complete the test callback."
1013
+ );
1014
+ }
1015
+ return value;
1016
+ }
1017
+ async function runTestMiddleware(module, input, downstream) {
1018
+ return executeMiddlewarePipeline(
1019
+ module,
1020
+ input,
1021
+ async (execution) => {
1022
+ const request = new Request(
1023
+ execution.url,
1024
+ {
1025
+ method: execution.method ?? "GET",
1026
+ headers: execution.headers
1027
+ }
1028
+ );
1029
+ return downstream ? downstream(request) : new Response(
1030
+ null,
1031
+ {
1032
+ status: 204
1033
+ }
1034
+ );
1035
+ }
1036
+ );
1037
+ }
1038
+ function createJobTestHarness(queue) {
1039
+ if (!queue) {
1040
+ throw new TypeError(
1041
+ "BCP Testing: job harness requires a queue."
1042
+ );
1043
+ }
1044
+ const harness = {
1045
+ queue,
1046
+ async drain(options = {}) {
1047
+ const maxJobs = positiveInteger(
1048
+ options.maxJobs ?? 1e3,
1049
+ "maxJobs"
1050
+ );
1051
+ let processed = 0;
1052
+ while (processed < maxJobs && !options.signal?.aborted) {
1053
+ const didProcess = await queue.processNext(
1054
+ options.signal
1055
+ );
1056
+ if (!didProcess) {
1057
+ break;
1058
+ }
1059
+ processed += 1;
1060
+ }
1061
+ return processed;
1062
+ },
1063
+ async records(name) {
1064
+ const records = await queue.list();
1065
+ return name ? records.filter(
1066
+ (record) => record.name === name
1067
+ ) : records;
1068
+ },
1069
+ async count(options = {}) {
1070
+ const records = await harness.records(
1071
+ options.name
1072
+ );
1073
+ return records.filter(
1074
+ (record) => !options.state || record.state === options.state
1075
+ ).length;
1076
+ },
1077
+ async expectCount(expected, options = {}) {
1078
+ const actual = await harness.count(options);
1079
+ if (actual !== expected) {
1080
+ throw new Error(
1081
+ `BCP Testing: expected ${expected} jobs, received ${actual}.`
1082
+ );
1083
+ }
1084
+ }
1085
+ };
1086
+ return harness;
1087
+ }
1088
+ function createWorkflowTestHarness(workflow) {
1089
+ if (!workflow) {
1090
+ throw new TypeError(
1091
+ "BCP Testing: workflow harness requires a workflow."
1092
+ );
1093
+ }
1094
+ const harness = {
1095
+ workflow,
1096
+ async startAndRun(input, options = {}) {
1097
+ const run = await workflow.start(
1098
+ input,
1099
+ {
1100
+ id: options.id
1101
+ }
1102
+ );
1103
+ return harness.runUntilIdle(
1104
+ run.id,
1105
+ options
1106
+ );
1107
+ },
1108
+ async runUntilIdle(id, options = {}) {
1109
+ const maxPasses = positiveInteger(
1110
+ options.maxPasses ?? 100,
1111
+ "workflow maxPasses"
1112
+ );
1113
+ let run = await requireWorkflowRun(
1114
+ workflow,
1115
+ id
1116
+ );
1117
+ for (let pass = 0; pass < maxPasses; pass += 1) {
1118
+ if (isWorkflowTerminal(run.state)) {
1119
+ return run;
1120
+ }
1121
+ if (run.state === "waiting") {
1122
+ if (!options.forceWaiting) {
1123
+ return run;
1124
+ }
1125
+ run = await workflow.resume(
1126
+ id,
1127
+ {
1128
+ force: true
1129
+ }
1130
+ );
1131
+ continue;
1132
+ }
1133
+ run = await workflow.run(id);
1134
+ }
1135
+ throw new Error(
1136
+ `BCP Testing: workflow "${id}" did not become idle within ${maxPasses} passes.`
1137
+ );
1138
+ },
1139
+ async expectState(id, expected) {
1140
+ const run = await requireWorkflowRun(
1141
+ workflow,
1142
+ id
1143
+ );
1144
+ if (run.state !== expected) {
1145
+ throw new Error(
1146
+ `BCP Testing: expected workflow "${id}" state ${expected}, received ${run.state}.`
1147
+ );
1148
+ }
1149
+ return run;
1150
+ }
1151
+ };
1152
+ return harness;
1153
+ }
1154
+ function createOutboxTestHarness(store, dispatcher) {
1155
+ if (!store || !dispatcher) {
1156
+ throw new TypeError(
1157
+ "BCP Testing: outbox harness requires a store and dispatcher."
1158
+ );
1159
+ }
1160
+ return {
1161
+ store,
1162
+ dispatcher,
1163
+ async dispatchUntilIdle(maxBatches = 100) {
1164
+ const limit = positiveInteger(
1165
+ maxBatches,
1166
+ "outbox maxBatches"
1167
+ );
1168
+ let published = 0;
1169
+ for (let batch = 0; batch < limit; batch += 1) {
1170
+ const count = await dispatcher.dispatchBatch();
1171
+ published += count;
1172
+ if (count === 0) {
1173
+ return published;
1174
+ }
1175
+ }
1176
+ return published;
1177
+ },
1178
+ async events(type) {
1179
+ const events = await store.list();
1180
+ return type ? events.filter(
1181
+ (event) => event.type === type
1182
+ ) : events;
1183
+ },
1184
+ stats() {
1185
+ return store.stats();
1186
+ },
1187
+ async expectState(id, expected) {
1188
+ const event = await store.get(id);
1189
+ if (!event) {
1190
+ throw new Error(
1191
+ `BCP Testing: outbox event "${id}" does not exist.`
1192
+ );
1193
+ }
1194
+ if (event.state !== expected) {
1195
+ throw new Error(
1196
+ `BCP Testing: expected outbox event "${id}" state ${expected}, received ${event.state}.`
1197
+ );
1198
+ }
1199
+ return event;
1200
+ }
1201
+ };
1202
+ }
1203
+ function createRealtimeTestSocket() {
1204
+ const sentMessages = [];
1205
+ const messageListeners = /* @__PURE__ */ new Set();
1206
+ const closeListeners = /* @__PURE__ */ new Set();
1207
+ const errorListeners = /* @__PURE__ */ new Set();
1208
+ let closed = false;
1209
+ let closeCode;
1210
+ let closeReason;
1211
+ const socket = {
1212
+ get closed() {
1213
+ return closed;
1214
+ },
1215
+ get closeCode() {
1216
+ return closeCode;
1217
+ },
1218
+ get closeReason() {
1219
+ return closeReason;
1220
+ },
1221
+ send(data) {
1222
+ if (closed) {
1223
+ throw new Error(
1224
+ "BCP Testing: realtime test socket is closed."
1225
+ );
1226
+ }
1227
+ sentMessages.push(
1228
+ String(data)
1229
+ );
1230
+ },
1231
+ async close(code = 1e3, reason = "server closed") {
1232
+ if (closed) {
1233
+ return;
1234
+ }
1235
+ closed = true;
1236
+ closeCode = code;
1237
+ closeReason = reason;
1238
+ await notify(
1239
+ closeListeners,
1240
+ (listener) => listener()
1241
+ );
1242
+ },
1243
+ onMessage(listener) {
1244
+ messageListeners.add(listener);
1245
+ return () => {
1246
+ messageListeners.delete(listener);
1247
+ };
1248
+ },
1249
+ onClose(listener) {
1250
+ closeListeners.add(listener);
1251
+ return () => {
1252
+ closeListeners.delete(listener);
1253
+ };
1254
+ },
1255
+ onError(listener) {
1256
+ errorListeners.add(listener);
1257
+ return () => {
1258
+ errorListeners.delete(listener);
1259
+ };
1260
+ },
1261
+ async receive(message) {
1262
+ if (closed) {
1263
+ throw new Error(
1264
+ "BCP Testing: cannot receive on a closed realtime test socket."
1265
+ );
1266
+ }
1267
+ const raw = typeof message === "string" ? message : JSON.stringify(message);
1268
+ await notify(
1269
+ messageListeners,
1270
+ (listener) => listener(raw)
1271
+ );
1272
+ },
1273
+ async closeFromClient(code = 1e3, reason = "client closed") {
1274
+ if (closed) {
1275
+ return;
1276
+ }
1277
+ closed = true;
1278
+ closeCode = code;
1279
+ closeReason = reason;
1280
+ await notify(
1281
+ closeListeners,
1282
+ (listener) => listener()
1283
+ );
1284
+ },
1285
+ async fail(error) {
1286
+ await notify(
1287
+ errorListeners,
1288
+ (listener) => listener(error)
1289
+ );
1290
+ },
1291
+ sent() {
1292
+ return [
1293
+ ...sentMessages
1294
+ ];
1295
+ },
1296
+ messages() {
1297
+ return sentMessages.map(
1298
+ (message) => JSON.parse(message)
1299
+ );
1300
+ },
1301
+ clear() {
1302
+ sentMessages.length = 0;
1303
+ }
1304
+ };
1305
+ return socket;
1306
+ }
1307
+ function createRealtimeTestHarness(hub) {
1308
+ if (!hub) {
1309
+ throw new TypeError(
1310
+ "BCP Testing: realtime harness requires a hub."
1311
+ );
1312
+ }
1313
+ const harness = {
1314
+ hub,
1315
+ async connect(options = {}) {
1316
+ const socket = createRealtimeTestSocket();
1317
+ const connection = await hub.attachSocket(
1318
+ socket,
1319
+ options
1320
+ );
1321
+ return {
1322
+ socket,
1323
+ connection
1324
+ };
1325
+ },
1326
+ expectEvent(socket, event, channel) {
1327
+ const normalizedEvent = normalizeNonEmpty(
1328
+ event,
1329
+ "realtime event"
1330
+ );
1331
+ const messages = socket.messages();
1332
+ const found = [...messages].reverse().find(
1333
+ (message) => message.type === "event" && message.event === normalizedEvent && (channel === void 0 || message.channel === channel)
1334
+ );
1335
+ if (!found || !found.id || !found.channel || found.timestamp === void 0) {
1336
+ throw new Error(
1337
+ `BCP Testing: realtime event "${normalizedEvent}" was not sent.`
1338
+ );
1339
+ }
1340
+ return {
1341
+ id: found.id,
1342
+ channel: found.channel,
1343
+ event: normalizedEvent,
1344
+ payload: found.payload,
1345
+ timestamp: found.timestamp,
1346
+ sourceId: found.sourceId,
1347
+ excludeConnectionId: found.excludeConnectionId
1348
+ };
1349
+ }
1350
+ };
1351
+ return harness;
1352
+ }
1353
+ async function readSseEvents(response, options = {}) {
1354
+ if (!(response instanceof Response)) {
1355
+ throw new TypeError(
1356
+ "BCP Testing: readSseEvents requires a Response."
1357
+ );
1358
+ }
1359
+ if (!response.body) {
1360
+ return [];
1361
+ }
1362
+ const limit = positiveInteger(
1363
+ options.limit ?? 1,
1364
+ "SSE event limit"
1365
+ );
1366
+ const timeoutMs = positiveInteger(
1367
+ options.timeoutMs ?? 1e3,
1368
+ "SSE timeoutMs"
1369
+ );
1370
+ const reader = response.body.getReader();
1371
+ const decoder = new TextDecoder();
1372
+ const events = [];
1373
+ let buffer = "";
1374
+ try {
1375
+ while (events.length < limit) {
1376
+ const result = await readWithTimeout(
1377
+ reader,
1378
+ timeoutMs
1379
+ );
1380
+ if (result.done) {
1381
+ break;
1382
+ }
1383
+ buffer += decoder.decode(
1384
+ result.value,
1385
+ {
1386
+ stream: true
1387
+ }
1388
+ );
1389
+ let boundary = buffer.indexOf("\n\n");
1390
+ while (boundary >= 0) {
1391
+ const frame = buffer.slice(
1392
+ 0,
1393
+ boundary
1394
+ );
1395
+ buffer = buffer.slice(
1396
+ boundary + 2
1397
+ );
1398
+ const event = parseSseFrame(
1399
+ frame
1400
+ );
1401
+ if (event) {
1402
+ events.push(event);
1403
+ if (events.length >= limit) {
1404
+ break;
1405
+ }
1406
+ }
1407
+ boundary = buffer.indexOf("\n\n");
1408
+ }
1409
+ }
1410
+ } finally {
1411
+ await reader.cancel().catch(
1412
+ () => void 0
1413
+ );
1414
+ }
1415
+ return events;
1416
+ }
1417
+ var TestRollbackSignal = class extends Error {
1418
+ constructor(marker) {
1419
+ super("BCP Testing rollback");
1420
+ this.marker = marker;
1421
+ this.name = "BcpTestRollbackSignal";
1422
+ }
1423
+ marker;
1424
+ };
1425
+ var knownRouteMethods = [
1426
+ "GET",
1427
+ "POST",
1428
+ "PUT",
1429
+ "PATCH",
1430
+ "DELETE",
1431
+ "HEAD",
1432
+ "OPTIONS"
1433
+ ];
1434
+ function normalizeRouteResponse(value, method) {
1435
+ if (value instanceof Response) {
1436
+ if (method !== "HEAD") {
1437
+ return value;
1438
+ }
1439
+ return new Response(
1440
+ null,
1441
+ {
1442
+ status: value.status,
1443
+ statusText: value.statusText,
1444
+ headers: value.headers
1445
+ }
1446
+ );
1447
+ }
1448
+ if (value === void 0 || value === null) {
1449
+ return new Response(
1450
+ null,
1451
+ {
1452
+ status: 204
1453
+ }
1454
+ );
1455
+ }
1456
+ if (typeof value === "string") {
1457
+ return new Response(value);
1458
+ }
1459
+ return Response.json(value);
1460
+ }
1461
+ async function requireWorkflowRun(workflow, id) {
1462
+ const run = await workflow.get(id);
1463
+ if (!run) {
1464
+ throw new Error(
1465
+ `BCP Testing: workflow run "${id}" does not exist.`
1466
+ );
1467
+ }
1468
+ return run;
1469
+ }
1470
+ function isWorkflowTerminal(state) {
1471
+ return state === "succeeded" || state === "failed" || state === "cancelled" || state === "compensated";
1472
+ }
1473
+ async function notify(listeners, invoke) {
1474
+ for (const listener of [
1475
+ ...listeners
1476
+ ]) {
1477
+ await invoke(listener);
1478
+ }
1479
+ }
1480
+ function parseSseFrame(frame) {
1481
+ const lines = frame.split(/\r?\n/);
1482
+ let id;
1483
+ let event;
1484
+ const data = [];
1485
+ for (const line of lines) {
1486
+ if (!line || line.startsWith(":") || line.startsWith("retry:")) {
1487
+ continue;
1488
+ }
1489
+ if (line.startsWith("id:")) {
1490
+ id = line.slice(3).trimStart();
1491
+ continue;
1492
+ }
1493
+ if (line.startsWith("event:")) {
1494
+ event = line.slice(6).trimStart();
1495
+ continue;
1496
+ }
1497
+ if (line.startsWith("data:")) {
1498
+ data.push(
1499
+ line.slice(5).trimStart()
1500
+ );
1501
+ }
1502
+ }
1503
+ if (id === void 0 && event === void 0 && data.length === 0) {
1504
+ return null;
1505
+ }
1506
+ const rawData = data.join("\n");
1507
+ let parsed;
1508
+ if (rawData) {
1509
+ try {
1510
+ parsed = JSON.parse(rawData);
1511
+ } catch {
1512
+ parsed = rawData;
1513
+ }
1514
+ }
1515
+ return {
1516
+ id,
1517
+ event,
1518
+ data: parsed,
1519
+ rawData: rawData || void 0
1520
+ };
1521
+ }
1522
+ function readWithTimeout(reader, timeoutMs) {
1523
+ return new Promise(
1524
+ (resolve, reject) => {
1525
+ const timer = setTimeout(
1526
+ () => {
1527
+ reject(
1528
+ new Error(
1529
+ `BCP Testing: timed out waiting for SSE data after ${timeoutMs}ms.`
1530
+ )
1531
+ );
1532
+ },
1533
+ timeoutMs
1534
+ );
1535
+ reader.read().then(
1536
+ (value) => {
1537
+ clearTimeout(timer);
1538
+ resolve(value);
1539
+ },
1540
+ (error) => {
1541
+ clearTimeout(timer);
1542
+ reject(error);
1543
+ }
1544
+ );
1545
+ }
1546
+ );
1547
+ }
1548
+ function updateCookieJar(jar, headers2) {
1549
+ const extended = headers2;
1550
+ const values = typeof extended.getSetCookie === "function" ? extended.getSetCookie() : headers2.get("set-cookie") ? [
1551
+ headers2.get("set-cookie")
1552
+ ] : [];
1553
+ for (const value of values) {
1554
+ const first = value.split(";", 1)[0] ?? "";
1555
+ const separator = first.indexOf("=");
1556
+ if (separator <= 0) {
1557
+ continue;
1558
+ }
1559
+ const name = first.slice(0, separator).trim();
1560
+ const cookieValue = first.slice(separator + 1).trim();
1561
+ const lower = value.toLowerCase();
1562
+ if (cookieValue === "" || lower.includes("max-age=0") || lower.includes("expires=thu, 01 jan 1970")) {
1563
+ jar.delete(name);
1564
+ } else {
1565
+ jar.set(name, cookieValue);
1566
+ }
1567
+ }
1568
+ }
1569
+ function serializeCookies(cookies2) {
1570
+ return Array.from(
1571
+ cookies2,
1572
+ ([name, value]) => `${name}=${value}`
1573
+ ).join("; ");
1574
+ }
1575
+ function normalizeMethod(value) {
1576
+ const method = String(value).trim().toUpperCase();
1577
+ if (!method) {
1578
+ throw new TypeError(
1579
+ "BCP Testing: request method must be a non-empty string."
1580
+ );
1581
+ }
1582
+ return method;
1583
+ }
1584
+ function normalizeBaseUrl(value) {
1585
+ let url;
1586
+ try {
1587
+ url = new URL(value);
1588
+ } catch {
1589
+ throw new TypeError(
1590
+ "BCP Testing: baseUrl must be an absolute URL."
1591
+ );
1592
+ }
1593
+ return url.href.endsWith("/") ? url.href : `${url.href}/`;
1594
+ }
1595
+ function resolveTestUrl(baseUrl, path) {
1596
+ const value = String(path ?? "").trim();
1597
+ if (!value) {
1598
+ return baseUrl;
1599
+ }
1600
+ try {
1601
+ return new URL(value).href;
1602
+ } catch {
1603
+ return new URL(
1604
+ value.replace(/^\//, ""),
1605
+ baseUrl
1606
+ ).href;
1607
+ }
1608
+ }
1609
+ function normalizeCookieName(value) {
1610
+ const name = normalizeNonEmpty(
1611
+ value,
1612
+ "cookie name"
1613
+ );
1614
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) {
1615
+ throw new TypeError(
1616
+ "BCP Testing: cookie name contains invalid characters."
1617
+ );
1618
+ }
1619
+ return name;
1620
+ }
1621
+ function normalizeHeaderName(value) {
1622
+ return normalizeNonEmpty(
1623
+ value,
1624
+ "header name"
1625
+ );
1626
+ }
1627
+ function normalizeNonEmpty(value, field) {
1628
+ const text = String(value ?? "").trim();
1629
+ if (!text) {
1630
+ throw new TypeError(
1631
+ `BCP Testing: ${field} must be a non-empty string.`
1632
+ );
1633
+ }
1634
+ return text;
1635
+ }
1636
+ function positiveInteger(value, field) {
1637
+ if (!Number.isInteger(value) || value <= 0) {
1638
+ throw new TypeError(
1639
+ `BCP Testing: ${field} must be a positive integer.`
1640
+ );
1641
+ }
1642
+ return value;
1643
+ }
1644
+ function assertFinite(value, field) {
1645
+ if (!Number.isFinite(value)) {
1646
+ throw new TypeError(
1647
+ `BCP Testing: ${field} must be finite.`
1648
+ );
1649
+ }
1650
+ }
1651
+ function safeJson(value) {
1652
+ try {
1653
+ return JSON.stringify(value);
1654
+ } catch {
1655
+ return String(value);
1656
+ }
1657
+ }
1658
+
1659
+ // packages/server/src/page-guard.ts
1660
+ async function executePageGuardFunctions(guards, params, requestUrl) {
1661
+ if (guards.length === 0) {
1662
+ return emptyGuardExecution();
1663
+ }
1664
+ const guardData = {};
1665
+ for (const definition of guards) {
1666
+ const result = await definition.guard({
1667
+ params: {
1668
+ ...params
1669
+ },
1670
+ searchParams: new URLSearchParams(
1671
+ requestUrl.searchParams
1672
+ ),
1673
+ guardData: {
1674
+ ...guardData
1675
+ }
1676
+ });
1677
+ if (result instanceof Response) {
1678
+ return {
1679
+ hasGuard: true,
1680
+ data: guardData,
1681
+ response: result
1682
+ };
1683
+ }
1684
+ if (result === void 0) {
1685
+ continue;
1686
+ }
1687
+ assertGuardData(
1688
+ result,
1689
+ definition.filePath
1690
+ );
1691
+ Object.assign(
1692
+ guardData,
1693
+ result
1694
+ );
1695
+ }
1696
+ return {
1697
+ hasGuard: true,
1698
+ data: guardData,
1699
+ response: null
1700
+ };
1701
+ }
1702
+ function emptyGuardExecution() {
1703
+ return {
1704
+ hasGuard: false,
1705
+ data: {},
1706
+ response: null
1707
+ };
1708
+ }
1709
+ function assertGuardData(value, label) {
1710
+ if (value === null || Array.isArray(
1711
+ value
1712
+ ) || typeof value !== "object") {
1713
+ throw new Error(
1714
+ `BCP Framework: guard "${label}" must return a plain object, undefined, or a Response.`
1715
+ );
1716
+ }
1717
+ const prototype = Object.getPrototypeOf(
1718
+ value
1719
+ );
1720
+ if (prototype !== Object.prototype && prototype !== null) {
1721
+ throw new Error(
1722
+ `BCP Framework: guard "${label}" must return a plain object, undefined, or a Response.`
1723
+ );
1724
+ }
1725
+ const seen = /* @__PURE__ */ new Set();
1726
+ validateJsonValue(
1727
+ value,
1728
+ "$",
1729
+ seen,
1730
+ label
1731
+ );
1732
+ }
1733
+ function validateJsonValue(value, location, seen, guardLabel) {
1734
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
1735
+ return;
1736
+ }
1737
+ if (typeof value === "number") {
1738
+ if (Number.isFinite(
1739
+ value
1740
+ )) {
1741
+ return;
1742
+ }
1743
+ throwInvalidGuardData(
1744
+ guardLabel,
1745
+ location,
1746
+ "numbers must be finite"
1747
+ );
1748
+ }
1749
+ if (typeof value !== "object") {
1750
+ throwInvalidGuardData(
1751
+ guardLabel,
1752
+ location,
1753
+ `unsupported ${typeof value} value`
1754
+ );
1755
+ }
1756
+ const objectValue = value;
1757
+ if (seen.has(objectValue)) {
1758
+ throwInvalidGuardData(
1759
+ guardLabel,
1760
+ location,
1761
+ "circular references are not supported"
1762
+ );
1763
+ }
1764
+ seen.add(
1765
+ objectValue
1766
+ );
1767
+ try {
1768
+ if (Array.isArray(value)) {
1769
+ value.forEach(
1770
+ (item, index) => {
1771
+ validateJsonValue(
1772
+ item,
1773
+ `${location}[${index}]`,
1774
+ seen,
1775
+ guardLabel
1776
+ );
1777
+ }
1778
+ );
1779
+ return;
1780
+ }
1781
+ const prototype = Object.getPrototypeOf(
1782
+ value
1783
+ );
1784
+ if (prototype !== Object.prototype && prototype !== null) {
1785
+ throwInvalidGuardData(
1786
+ guardLabel,
1787
+ location,
1788
+ "only plain objects and arrays are supported"
1789
+ );
1790
+ }
1791
+ for (const [
1792
+ key,
1793
+ item
1794
+ ] of Object.entries(
1795
+ value
1796
+ )) {
1797
+ validateJsonValue(
1798
+ item,
1799
+ `${location}.${key}`,
1800
+ seen,
1801
+ guardLabel
1802
+ );
1803
+ }
1804
+ } finally {
1805
+ seen.delete(
1806
+ objectValue
1807
+ );
1808
+ }
1809
+ }
1810
+ function throwInvalidGuardData(guardLabel, location, reason) {
1811
+ throw new Error(
1812
+ `BCP Framework: guard "${guardLabel}" returned non-serializable data at ${location}: ${reason}.`
1813
+ );
1814
+ }
1815
+
1816
+ // packages/server/src/form-action.ts
1817
+ var PAGE_ACTION_METHODS = [
1818
+ "POST",
1819
+ "PUT",
1820
+ "PATCH",
1821
+ "DELETE"
1822
+ ];
1823
+ var ACTION_NAME_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
1824
+ var BLOCKED_ACTION_NAMES = /* @__PURE__ */ new Set([
1825
+ "default",
1826
+ "__proto__",
1827
+ "prototype",
1828
+ "constructor"
1829
+ ]);
1830
+ function normalizePageActionMethod(value) {
1831
+ const method = (value ?? "POST").trim().toUpperCase();
1832
+ if (!PAGE_ACTION_METHODS.includes(
1833
+ method
1834
+ )) {
1835
+ throw new Error(
1836
+ `BCP Framework: unsupported form action method "${method || String(value)}". Use POST, PUT, PATCH, or DELETE.`
1837
+ );
1838
+ }
1839
+ return method;
1840
+ }
1841
+ function assertPageActionName(name) {
1842
+ if (!ACTION_NAME_PATTERN.test(
1843
+ name
1844
+ ) || BLOCKED_ACTION_NAMES.has(
1845
+ name
1846
+ )) {
1847
+ throw new Error(
1848
+ `BCP Framework: invalid form action name "${name}".`
1849
+ );
1850
+ }
1851
+ }
1852
+ async function executePageActionFunction(action, actionName, method, formData, params, requestUrl, guardData = {}, hasGuard = false, label = "actions.ts") {
1853
+ assertPageActionName(
1854
+ actionName
1855
+ );
1856
+ const result = await action(
1857
+ formData,
1858
+ {
1859
+ params: {
1860
+ ...params
1861
+ },
1862
+ searchParams: new URLSearchParams(
1863
+ requestUrl.searchParams
1864
+ ),
1865
+ guardData: {
1866
+ ...guardData
1867
+ },
1868
+ method
1869
+ }
1870
+ );
1871
+ if (result instanceof Response) {
1872
+ return {
1873
+ hasGuard,
1874
+ guardData: {
1875
+ ...guardData
1876
+ },
1877
+ data: null,
1878
+ response: result
1879
+ };
1880
+ }
1881
+ const data = result === void 0 ? null : result;
1882
+ assertActionData(
1883
+ data,
1884
+ `${label}#${actionName}`
1885
+ );
1886
+ return {
1887
+ hasGuard,
1888
+ guardData: {
1889
+ ...guardData
1890
+ },
1891
+ data,
1892
+ response: null
1893
+ };
1894
+ }
1895
+ function assertActionData(value, actionLabel = "action") {
1896
+ const seen = /* @__PURE__ */ new Set();
1897
+ validateJsonValue2(
1898
+ value,
1899
+ "$",
1900
+ seen,
1901
+ actionLabel
1902
+ );
1903
+ }
1904
+ function validateJsonValue2(value, location, seen, actionLabel) {
1905
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
1906
+ return;
1907
+ }
1908
+ if (typeof value === "number") {
1909
+ if (Number.isFinite(
1910
+ value
1911
+ )) {
1912
+ return;
1913
+ }
1914
+ throwInvalidActionData(
1915
+ actionLabel,
1916
+ location,
1917
+ "numbers must be finite"
1918
+ );
1919
+ }
1920
+ if (typeof value !== "object") {
1921
+ throwInvalidActionData(
1922
+ actionLabel,
1923
+ location,
1924
+ `unsupported ${typeof value} value`
1925
+ );
1926
+ }
1927
+ const objectValue = value;
1928
+ if (seen.has(objectValue)) {
1929
+ throwInvalidActionData(
1930
+ actionLabel,
1931
+ location,
1932
+ "circular references are not supported"
1933
+ );
1934
+ }
1935
+ seen.add(
1936
+ objectValue
1937
+ );
1938
+ try {
1939
+ if (Array.isArray(value)) {
1940
+ value.forEach(
1941
+ (item, index) => {
1942
+ validateJsonValue2(
1943
+ item,
1944
+ `${location}[${index}]`,
1945
+ seen,
1946
+ actionLabel
1947
+ );
1948
+ }
1949
+ );
1950
+ return;
1951
+ }
1952
+ const prototype = Object.getPrototypeOf(
1953
+ value
1954
+ );
1955
+ if (prototype !== Object.prototype && prototype !== null) {
1956
+ throwInvalidActionData(
1957
+ actionLabel,
1958
+ location,
1959
+ "only plain objects and arrays are supported"
1960
+ );
1961
+ }
1962
+ for (const [
1963
+ key,
1964
+ item
1965
+ ] of Object.entries(
1966
+ value
1967
+ )) {
1968
+ validateJsonValue2(
1969
+ item,
1970
+ `${location}.${key}`,
1971
+ seen,
1972
+ actionLabel
1973
+ );
1974
+ }
1975
+ } finally {
1976
+ seen.delete(
1977
+ objectValue
1978
+ );
1979
+ }
1980
+ }
1981
+ function throwInvalidActionData(actionLabel, location, reason) {
1982
+ throw new Error(
1983
+ `BCP Framework: action "${actionLabel}" returned non-serializable data at ${location}: ${reason}.`
1984
+ );
1985
+ }
1986
+
1987
+ // packages/server/src/page-loader.ts
1988
+ var GUARD_DATA_HEADER = "x-bcp-guard-data";
1989
+ async function executePageLoaderFunction(loader, params, requestUrl, label = "loader", explicitGuardData, explicitHasGuard) {
1990
+ const headerGuardData = explicitGuardData === void 0 ? await readGuardDataHeader() : null;
1991
+ const guardData = explicitGuardData ?? headerGuardData?.data ?? {};
1992
+ const hasGuard = explicitHasGuard ?? headerGuardData?.hasGuard ?? false;
1993
+ const result = await loader({
1994
+ params: {
1995
+ ...params
1996
+ },
1997
+ searchParams: new URLSearchParams(
1998
+ requestUrl.searchParams
1999
+ ),
2000
+ guardData: {
2001
+ ...guardData
2002
+ }
2003
+ });
2004
+ if (result instanceof Response) {
2005
+ return createExecution({
2006
+ hasPageLoader: true,
2007
+ hasGuard,
2008
+ loaderData: void 0,
2009
+ guardData,
2010
+ response: result
2011
+ });
2012
+ }
2013
+ assertLoaderData(
2014
+ result,
2015
+ label
2016
+ );
2017
+ return createExecution({
2018
+ hasPageLoader: true,
2019
+ hasGuard,
2020
+ loaderData: result,
2021
+ guardData,
2022
+ response: null
2023
+ });
2024
+ }
2025
+ function createExecution(input) {
2026
+ const data = {
2027
+ __bcp_server_data: true,
2028
+ hasPageLoader: input.hasPageLoader,
2029
+ hasGuard: input.hasGuard,
2030
+ loaderData: input.loaderData,
2031
+ guardData: {
2032
+ ...input.guardData
2033
+ }
2034
+ };
2035
+ return {
2036
+ hasLoader: input.hasPageLoader || input.hasGuard,
2037
+ hasPageLoader: input.hasPageLoader,
2038
+ hasGuard: input.hasGuard,
2039
+ data,
2040
+ loaderData: input.loaderData,
2041
+ guardData: data.guardData,
2042
+ response: input.response
2043
+ };
2044
+ }
2045
+ async function readGuardDataHeader() {
2046
+ let requestHeaders;
2047
+ try {
2048
+ requestHeaders = await headers();
2049
+ } catch {
2050
+ return null;
2051
+ }
2052
+ const encoded = requestHeaders.get(
2053
+ GUARD_DATA_HEADER
2054
+ );
2055
+ if (!encoded) {
2056
+ return null;
2057
+ }
2058
+ let parsed;
2059
+ try {
2060
+ parsed = JSON.parse(
2061
+ Buffer.from(
2062
+ encoded,
2063
+ "base64url"
2064
+ ).toString(
2065
+ "utf8"
2066
+ )
2067
+ );
2068
+ } catch {
2069
+ throw new Error(
2070
+ "BCP Framework: invalid internal guard data header."
2071
+ );
2072
+ }
2073
+ assertGuardHeaderData(
2074
+ parsed
2075
+ );
2076
+ return {
2077
+ hasGuard: true,
2078
+ data: parsed
2079
+ };
2080
+ }
2081
+ function assertGuardHeaderData(value) {
2082
+ if (value === null || Array.isArray(
2083
+ value
2084
+ ) || typeof value !== "object") {
2085
+ throw new Error(
2086
+ "BCP Framework: invalid internal guard data header."
2087
+ );
2088
+ }
2089
+ const prototype = Object.getPrototypeOf(
2090
+ value
2091
+ );
2092
+ if (prototype !== Object.prototype && prototype !== null) {
2093
+ throw new Error(
2094
+ "BCP Framework: invalid internal guard data header."
2095
+ );
2096
+ }
2097
+ }
2098
+ function assertLoaderData(value, loaderLabel) {
2099
+ const seen = /* @__PURE__ */ new Set();
2100
+ validateJsonValue3(
2101
+ value,
2102
+ "$",
2103
+ seen,
2104
+ loaderLabel
2105
+ );
2106
+ }
2107
+ function validateJsonValue3(value, location, seen, loaderLabel) {
2108
+ if (value === null || typeof value === "string" || typeof value === "boolean") {
2109
+ return;
2110
+ }
2111
+ if (typeof value === "number") {
2112
+ if (Number.isFinite(
2113
+ value
2114
+ )) {
2115
+ return;
2116
+ }
2117
+ throwInvalidLoaderData(
2118
+ loaderLabel,
2119
+ location,
2120
+ "numbers must be finite"
2121
+ );
2122
+ }
2123
+ if (typeof value !== "object") {
2124
+ throwInvalidLoaderData(
2125
+ loaderLabel,
2126
+ location,
2127
+ `unsupported ${typeof value} value`
2128
+ );
2129
+ }
2130
+ const objectValue = value;
2131
+ if (seen.has(objectValue)) {
2132
+ throwInvalidLoaderData(
2133
+ loaderLabel,
2134
+ location,
2135
+ "circular references are not supported"
2136
+ );
2137
+ }
2138
+ seen.add(
2139
+ objectValue
2140
+ );
2141
+ try {
2142
+ if (Array.isArray(value)) {
2143
+ value.forEach(
2144
+ (item, index) => {
2145
+ validateJsonValue3(
2146
+ item,
2147
+ `${location}[${index}]`,
2148
+ seen,
2149
+ loaderLabel
2150
+ );
2151
+ }
2152
+ );
2153
+ return;
2154
+ }
2155
+ const prototype = Object.getPrototypeOf(
2156
+ value
2157
+ );
2158
+ if (prototype !== Object.prototype && prototype !== null) {
2159
+ throwInvalidLoaderData(
2160
+ loaderLabel,
2161
+ location,
2162
+ "only plain objects and arrays are supported"
2163
+ );
2164
+ }
2165
+ for (const [
2166
+ key,
2167
+ item
2168
+ ] of Object.entries(
2169
+ value
2170
+ )) {
2171
+ validateJsonValue3(
2172
+ item,
2173
+ `${location}.${key}`,
2174
+ seen,
2175
+ loaderLabel
2176
+ );
2177
+ }
2178
+ } finally {
2179
+ seen.delete(
2180
+ objectValue
2181
+ );
2182
+ }
2183
+ }
2184
+ function throwInvalidLoaderData(loaderLabel, location, reason) {
2185
+ throw new Error(
2186
+ `BCP Framework: loader "${loaderLabel}" returned non-serializable data at ${location}: ${reason}.`
2187
+ );
2188
+ }
2189
+
2190
+ // packages/server/src/testing-page.ts
2191
+ async function runTestPageLoader(loader, options = {}) {
2192
+ assertFunction(
2193
+ loader,
2194
+ "page loader"
2195
+ );
2196
+ return executePageLoaderFunction(
2197
+ loader,
2198
+ {
2199
+ ...options.params ?? {}
2200
+ },
2201
+ resolveTestPageUrl(
2202
+ options.url
2203
+ ),
2204
+ "bcp/testing loader",
2205
+ {
2206
+ ...options.guardData ?? {}
2207
+ },
2208
+ options.hasGuard ?? Object.keys(
2209
+ options.guardData ?? {}
2210
+ ).length > 0
2211
+ );
2212
+ }
2213
+ async function runTestPageGuards(guards, options = {}) {
2214
+ const list = Array.isArray(guards) ? guards : [guards];
2215
+ const definitions = list.map(
2216
+ (candidate, index) => {
2217
+ if (typeof candidate === "object" && candidate !== null && "guard" in candidate) {
2218
+ const definition = candidate;
2219
+ assertFunction(
2220
+ definition.guard,
2221
+ `page guard ${index + 1}`
2222
+ );
2223
+ return {
2224
+ filePath: definition.filePath || `${options.label ?? "bcp/testing guard"}#${index + 1}`,
2225
+ guard: definition.guard
2226
+ };
2227
+ }
2228
+ assertFunction(
2229
+ candidate,
2230
+ `page guard ${index + 1}`
2231
+ );
2232
+ return {
2233
+ filePath: `${options.label ?? "bcp/testing guard"}#${index + 1}`,
2234
+ guard: candidate
2235
+ };
2236
+ }
2237
+ );
2238
+ return executePageGuardFunctions(
2239
+ definitions,
2240
+ {
2241
+ ...options.params ?? {}
2242
+ },
2243
+ resolveTestPageUrl(
2244
+ options.url
2245
+ )
2246
+ );
2247
+ }
2248
+ async function runTestPageAction(action, options = {}) {
2249
+ assertFunction(
2250
+ action,
2251
+ "page action"
2252
+ );
2253
+ if (options.formData && options.fields) {
2254
+ throw new TypeError(
2255
+ "BCP Testing: page action test cannot specify both formData and fields."
2256
+ );
2257
+ }
2258
+ const actionName = normalizeText(
2259
+ options.actionName ?? "action",
2260
+ "page action name"
2261
+ );
2262
+ const method = normalizePageActionMethod(
2263
+ options.method ?? "POST"
2264
+ );
2265
+ const formData = options.formData ?? createTestFormData(
2266
+ options.fields ?? {}
2267
+ );
2268
+ return executePageActionFunction(
2269
+ action,
2270
+ actionName,
2271
+ method,
2272
+ formData,
2273
+ {
2274
+ ...options.params ?? {}
2275
+ },
2276
+ resolveTestPageUrl(
2277
+ options.url
2278
+ ),
2279
+ {
2280
+ ...options.guardData ?? {}
2281
+ },
2282
+ options.hasGuard ?? Object.keys(
2283
+ options.guardData ?? {}
2284
+ ).length > 0,
2285
+ options.label ?? "bcp/testing actions"
2286
+ );
2287
+ }
2288
+ function createTestFormData(fields = {}) {
2289
+ const formData = new FormData();
2290
+ for (const [name, rawValue] of Object.entries(fields)) {
2291
+ const values = Array.isArray(rawValue) ? rawValue : [rawValue];
2292
+ for (const value of values) {
2293
+ formData.append(
2294
+ name,
2295
+ value
2296
+ );
2297
+ }
2298
+ }
2299
+ return formData;
2300
+ }
2301
+ function resolveTestPageUrl(value) {
2302
+ if (value instanceof URL) {
2303
+ return new URL(
2304
+ value.href
2305
+ );
2306
+ }
2307
+ const candidate = value ?? "http://bcp.test/";
2308
+ try {
2309
+ return new URL(
2310
+ candidate
2311
+ );
2312
+ } catch {
2313
+ return new URL(
2314
+ String(candidate).replace(
2315
+ /^\//,
2316
+ ""
2317
+ ),
2318
+ "http://bcp.test/"
2319
+ );
2320
+ }
2321
+ }
2322
+ function assertFunction(value, label) {
2323
+ if (typeof value !== "function") {
2324
+ throw new TypeError(
2325
+ `BCP Testing: ${label} must be a function.`
2326
+ );
2327
+ }
2328
+ }
2329
+ function normalizeText(value, label) {
2330
+ const text = String(value ?? "").trim();
2331
+ if (!text) {
2332
+ throw new TypeError(
2333
+ `BCP Testing: ${label} must be a non-empty string.`
2334
+ );
2335
+ }
2336
+ return text;
2337
+ }
2338
+ export {
2339
+ createFakeClock,
2340
+ createJobTestHarness,
2341
+ createOutboxTestHarness,
2342
+ createRealtimeTestHarness,
2343
+ createRealtimeTestSocket,
2344
+ createRouteTestHandler,
2345
+ createSequenceIdFactory,
2346
+ createTestApp,
2347
+ createTestAuthSession,
2348
+ createTestFormData,
2349
+ createWorkflowTestHarness,
2350
+ expectResponse,
2351
+ readSseEvents,
2352
+ runTestMiddleware,
2353
+ runTestPageAction,
2354
+ runTestPageGuards,
2355
+ runTestPageLoader,
2356
+ withTestTransaction
2357
+ };