@spine-event-engine/server 2.0.0-snapshot.25 → 2.0.0-snapshot.28

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 (207) hide show
  1. package/README.md +104 -16
  2. package/REFERENCE.md +486 -57
  3. package/dist/agent/agent-history.d.ts +19 -3
  4. package/dist/agent/agent-history.d.ts.map +1 -1
  5. package/dist/agent/agent-history.js +41 -10
  6. package/dist/agent/agent-history.js.map +1 -1
  7. package/dist/agent/agent-live-ai-runtime.d.ts +1 -1
  8. package/dist/agent/agent-live-ai-runtime.js +1 -1
  9. package/dist/agent/agent-live-budget.d.ts +1 -1
  10. package/dist/agent/agent-live-budget.js +1 -1
  11. package/dist/agent/agent-live-read-runtime.d.ts +13 -2
  12. package/dist/agent/agent-live-read-runtime.d.ts.map +1 -1
  13. package/dist/agent/agent-live-read-runtime.js +6 -5
  14. package/dist/agent/agent-live-read-runtime.js.map +1 -1
  15. package/dist/browser/browser-admission.d.ts +47 -0
  16. package/dist/browser/browser-admission.d.ts.map +1 -0
  17. package/dist/browser/browser-admission.js +200 -0
  18. package/dist/browser/browser-admission.js.map +1 -0
  19. package/dist/browser/browser-server.d.ts +171 -1
  20. package/dist/browser/browser-server.d.ts.map +1 -1
  21. package/dist/browser/browser-server.js +1027 -303
  22. package/dist/browser/browser-server.js.map +1 -1
  23. package/dist/browser/durable-subscription-bindings.d.ts +5 -0
  24. package/dist/browser/durable-subscription-bindings.d.ts.map +1 -1
  25. package/dist/browser/durable-subscription-bindings.js +226 -38
  26. package/dist/browser/durable-subscription-bindings.js.map +1 -1
  27. package/dist/browser/index.d.ts +1 -1
  28. package/dist/browser/index.d.ts.map +1 -1
  29. package/dist/browser/options.d.ts +53 -2
  30. package/dist/browser/options.d.ts.map +1 -1
  31. package/dist/bus/command-bus.d.ts +9 -1
  32. package/dist/bus/command-bus.d.ts.map +1 -1
  33. package/dist/bus/command-bus.js +75 -10
  34. package/dist/bus/command-bus.js.map +1 -1
  35. package/dist/bus/command-errors.d.ts +20 -4
  36. package/dist/bus/command-errors.d.ts.map +1 -1
  37. package/dist/bus/command-errors.js +47 -7
  38. package/dist/bus/command-errors.js.map +1 -1
  39. package/dist/bus/event-bus.d.ts +9 -1
  40. package/dist/bus/event-bus.d.ts.map +1 -1
  41. package/dist/bus/event-bus.js +136 -9
  42. package/dist/bus/event-bus.js.map +1 -1
  43. package/dist/context/bounded-context.d.ts +59 -39
  44. package/dist/context/bounded-context.d.ts.map +1 -1
  45. package/dist/context/bounded-context.js +277 -262
  46. package/dist/context/bounded-context.js.map +1 -1
  47. package/dist/context/entity-inbox.d.ts +12 -17
  48. package/dist/context/entity-inbox.d.ts.map +1 -1
  49. package/dist/context/entity-inbox.js +106 -119
  50. package/dist/context/entity-inbox.js.map +1 -1
  51. package/dist/context/local-inbox-handoff.d.ts +13 -13
  52. package/dist/context/local-inbox-handoff.d.ts.map +1 -1
  53. package/dist/context/local-inbox-handoff.js +60 -32
  54. package/dist/context/local-inbox-handoff.js.map +1 -1
  55. package/dist/context/persistent-tenant-index.d.ts +81 -0
  56. package/dist/context/persistent-tenant-index.d.ts.map +1 -0
  57. package/dist/context/persistent-tenant-index.js +270 -0
  58. package/dist/context/persistent-tenant-index.js.map +1 -0
  59. package/dist/context/projection-handoff.d.ts +9 -9
  60. package/dist/context/projection-handoff.d.ts.map +1 -1
  61. package/dist/context/projection-handoff.js +31 -23
  62. package/dist/context/projection-handoff.js.map +1 -1
  63. package/dist/context/tenant-index.d.ts +7 -6
  64. package/dist/context/tenant-index.d.ts.map +1 -1
  65. package/dist/context/tenant-index.js +2 -188
  66. package/dist/context/tenant-index.js.map +1 -1
  67. package/dist/context/tenant-unknown-error.d.ts +2 -2
  68. package/dist/context/tenant-unknown-error.d.ts.map +1 -1
  69. package/dist/context/tenant-unknown-error.js +4 -1
  70. package/dist/context/tenant-unknown-error.js.map +1 -1
  71. package/dist/delivery/delivery-supervisor.d.ts.map +1 -1
  72. package/dist/delivery/delivery-supervisor.js +12 -3
  73. package/dist/delivery/delivery-supervisor.js.map +1 -1
  74. package/dist/delivery/delivery.d.ts +4 -3
  75. package/dist/delivery/delivery.d.ts.map +1 -1
  76. package/dist/delivery/delivery.js +82 -34
  77. package/dist/delivery/delivery.js.map +1 -1
  78. package/dist/delivery/reception-observation.d.ts +3 -3
  79. package/dist/delivery/reception-observation.js +2 -2
  80. package/dist/entity/entity.d.ts +59 -0
  81. package/dist/entity/entity.d.ts.map +1 -1
  82. package/dist/entity/entity.js +60 -0
  83. package/dist/entity/entity.js.map +1 -1
  84. package/dist/handler/app-query-handler.d.ts +60 -0
  85. package/dist/handler/app-query-handler.d.ts.map +1 -0
  86. package/dist/handler/app-query-handler.js +23 -0
  87. package/dist/handler/app-query-handler.js.map +1 -0
  88. package/dist/handler/generated-registry-discovery.d.ts +6 -0
  89. package/dist/handler/generated-registry-discovery.d.ts.map +1 -1
  90. package/dist/handler/generated-registry-discovery.js +71 -2
  91. package/dist/handler/generated-registry-discovery.js.map +1 -1
  92. package/dist/handler/handler-decorators.d.ts +8 -29
  93. package/dist/handler/handler-decorators.d.ts.map +1 -1
  94. package/dist/handler/handler-decorators.js +33 -210
  95. package/dist/handler/handler-decorators.js.map +1 -1
  96. package/dist/handler/spine-metadata-inventory.d.ts +99 -0
  97. package/dist/handler/spine-metadata-inventory.d.ts.map +1 -0
  98. package/dist/handler/spine-metadata-inventory.js +162 -0
  99. package/dist/handler/spine-metadata-inventory.js.map +1 -0
  100. package/dist/index.d.ts +9 -4
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +6 -2
  103. package/dist/index.js.map +1 -1
  104. package/dist/repository/command-routing.d.ts +1 -1
  105. package/dist/repository/command-routing.js +1 -1
  106. package/dist/repository/event-routing.d.ts +1 -1
  107. package/dist/repository/event-routing.js +1 -1
  108. package/dist/repository/repository.d.ts +61 -26
  109. package/dist/repository/repository.d.ts.map +1 -1
  110. package/dist/repository/repository.js +612 -306
  111. package/dist/repository/repository.js.map +1 -1
  112. package/dist/repository/state-update-routing.d.ts +1 -1
  113. package/dist/repository/state-update-routing.js +1 -1
  114. package/dist/runtime/signal-publisher.d.ts +23 -8
  115. package/dist/runtime/signal-publisher.d.ts.map +1 -1
  116. package/dist/runtime/signal-publisher.js +11 -8
  117. package/dist/runtime/signal-publisher.js.map +1 -1
  118. package/dist/server/managed-server-application.d.ts +12 -11
  119. package/dist/server/managed-server-application.d.ts.map +1 -1
  120. package/dist/server/managed-server-application.js +41 -25
  121. package/dist/server/managed-server-application.js.map +1 -1
  122. package/dist/server/node-coordinator.d.ts +18 -5
  123. package/dist/server/node-coordinator.d.ts.map +1 -1
  124. package/dist/server/node-coordinator.js +744 -108
  125. package/dist/server/node-coordinator.js.map +1 -1
  126. package/dist/server/reserved-spine-rpc-paths.json +1 -0
  127. package/dist/server/server-environment.d.ts +23 -5
  128. package/dist/server/server-environment.d.ts.map +1 -1
  129. package/dist/server/server-environment.js +89 -24
  130. package/dist/server/server-environment.js.map +1 -1
  131. package/dist/server/server.d.ts +45 -0
  132. package/dist/server/server.d.ts.map +1 -1
  133. package/dist/server/server.js +112 -3
  134. package/dist/server/server.js.map +1 -1
  135. package/dist/services/app-query-execution.d.ts +53 -0
  136. package/dist/services/app-query-execution.d.ts.map +1 -0
  137. package/dist/services/app-query-execution.js +408 -0
  138. package/dist/services/app-query-execution.js.map +1 -0
  139. package/dist/services/native-operation.d.ts +34 -0
  140. package/dist/services/native-operation.d.ts.map +1 -0
  141. package/dist/services/native-operation.js +77 -0
  142. package/dist/services/native-operation.js.map +1 -0
  143. package/dist/services/query-reader.d.ts +32 -7
  144. package/dist/services/query-reader.d.ts.map +1 -1
  145. package/dist/services/query-reader.js +22 -2
  146. package/dist/services/query-reader.js.map +1 -1
  147. package/dist/services/read-request-status.d.ts +65 -0
  148. package/dist/services/read-request-status.d.ts.map +1 -0
  149. package/dist/services/read-request-status.js +151 -0
  150. package/dist/services/read-request-status.js.map +1 -0
  151. package/dist/services/spine-services.d.ts +42 -7
  152. package/dist/services/spine-services.d.ts.map +1 -1
  153. package/dist/services/spine-services.js +1005 -175
  154. package/dist/services/spine-services.js.map +1 -1
  155. package/dist/services/trusted-reader-ingress.d.ts +110 -0
  156. package/dist/services/trusted-reader-ingress.d.ts.map +1 -0
  157. package/dist/services/trusted-reader-ingress.js +102 -0
  158. package/dist/services/trusted-reader-ingress.js.map +1 -0
  159. package/dist/spi/handler-registry.d.ts +2 -0
  160. package/dist/spi/handler-registry.d.ts.map +1 -1
  161. package/dist/spi/handler-registry.js +1 -1
  162. package/dist/spi/handler-registry.js.map +1 -1
  163. package/dist/stand/read-side-filter-engine.d.ts +46 -0
  164. package/dist/stand/read-side-filter-engine.d.ts.map +1 -0
  165. package/dist/stand/read-side-filter-engine.js +356 -0
  166. package/dist/stand/read-side-filter-engine.js.map +1 -0
  167. package/dist/stand/read-side-filter.d.ts +247 -0
  168. package/dist/stand/read-side-filter.d.ts.map +1 -0
  169. package/dist/stand/read-side-filter.js +39 -0
  170. package/dist/stand/read-side-filter.js.map +1 -0
  171. package/dist/stand/read-side-message.d.ts +66 -0
  172. package/dist/stand/read-side-message.d.ts.map +1 -0
  173. package/dist/stand/read-side-message.js +167 -0
  174. package/dist/stand/read-side-message.js.map +1 -0
  175. package/dist/stand/stand-direct-delivery.d.ts +161 -0
  176. package/dist/stand/stand-direct-delivery.d.ts.map +1 -0
  177. package/dist/stand/stand-direct-delivery.js +210 -0
  178. package/dist/stand/stand-direct-delivery.js.map +1 -0
  179. package/dist/stand/stand.d.ts +81 -43
  180. package/dist/stand/stand.d.ts.map +1 -1
  181. package/dist/stand/stand.js +411 -55
  182. package/dist/stand/stand.js.map +1 -1
  183. package/dist/stand/subscription-observer.d.ts.map +1 -1
  184. package/dist/stand/subscription-observer.js +103 -0
  185. package/dist/stand/subscription-observer.js.map +1 -1
  186. package/dist/stand/subscription-read-filters.d.ts +40 -0
  187. package/dist/stand/subscription-read-filters.d.ts.map +1 -0
  188. package/dist/stand/subscription-read-filters.js +91 -0
  189. package/dist/stand/subscription-read-filters.js.map +1 -0
  190. package/dist/stand/subscription-reader-binding.d.ts +20 -0
  191. package/dist/stand/subscription-reader-binding.d.ts.map +1 -0
  192. package/dist/stand/subscription-reader-binding.js +56 -0
  193. package/dist/stand/subscription-reader-binding.js.map +1 -0
  194. package/dist/stand/subscription-records.d.ts +30 -3
  195. package/dist/stand/subscription-records.d.ts.map +1 -1
  196. package/dist/stand/subscription-records.js +61 -16
  197. package/dist/stand/subscription-records.js.map +1 -1
  198. package/dist/stand/subscription-registry.d.ts +49 -14
  199. package/dist/stand/subscription-registry.d.ts.map +1 -1
  200. package/dist/stand/subscription-registry.js +150 -31
  201. package/dist/stand/subscription-registry.js.map +1 -1
  202. package/dist/stand/subscription-runtime.d.ts +7 -2
  203. package/dist/stand/subscription-runtime.d.ts.map +1 -1
  204. package/dist/stand/subscription-runtime.js +131 -25
  205. package/dist/stand/subscription-runtime.js.map +1 -1
  206. package/dist/tsconfig.tsbuildinfo +1 -1
  207. package/package.json +16 -14
@@ -15,12 +15,15 @@
15
15
  /* eslint-disable @typescript-eslint/no-unsafe-return */
16
16
  /* eslint-disable @typescript-eslint/require-await */
17
17
  import * as http from "node:http";
18
+ import { AsyncLocalStorage } from "node:async_hooks";
18
19
  import { createNativeGatewayServices, DynamicSubscriptionCreator, DynamicUnaryForwarder, InMemorySubscriptionBindings, NativeSubscriptionCreator, SubscriptionGateway, TransportFacts, UnaryGateway, } from "@spine-event-engine/auth";
20
+ import { Code, ConnectError, cors as connectCors, createContextKey, } from "@connectrpc/connect";
19
21
  import { connectNodeAdapter, createGrpcTransport, Http2SessionManager, } from "@connectrpc/connect-node";
20
22
  import { AuthenticationService } from "@spine-event-engine/proto/auth";
21
23
  import { ApplicationNode } from "@spine-event-engine/deployment";
22
24
  import { CommandService, QueryService, SubscriptionService, } from "@spine-event-engine/proto/client";
23
25
  import { Server, serverBuilderAccess } from "../server/server.js";
26
+ import { BrowserAdmissionPolicy } from "./browser-admission.js";
24
27
  import { attachDurableSubscriptionCleanup, isDurableSubscriptionBindings, } from "./durable-subscription-bindings.js";
25
28
  import reservedSpineRpcPaths from "../server/reserved-spine-rpc-paths.json" with { type: "json" };
26
29
  import { Environment, EnvironmentType } from "../server/environment.js";
@@ -28,12 +31,42 @@ import { ProcessServerCoordinator } from "../server/process-server-coordinator.j
28
31
  import { ServerEnvironment } from "../server/server-environment.js";
29
32
  const gracefulBrowserDrainMs = 100;
30
33
  const reservedPathSet = new Set(reservedSpineRpcPaths);
34
+ const credentialContext = new AsyncLocalStorage();
35
+ const admittedCredential = createContextKey(undefined, {
36
+ description: "Browser credential admitted for one Connect request",
37
+ });
38
+ const emptyAdmission = Object.freeze({
39
+ csrfHeaderNames: Object.freeze([]),
40
+ credentialCookieNames: Object.freeze([]),
41
+ stateCookieNames: Object.freeze([]),
42
+ });
43
+ const bearerCredentials = Object.freeze({
44
+ admission: emptyAdmission,
45
+ read: async ({ headers }) => {
46
+ const supplied = headers.authorization;
47
+ if (supplied === undefined)
48
+ return undefined;
49
+ if (typeof supplied !== "string")
50
+ return { kind: "rejected" };
51
+ const match = /^Bearer ([\x21-\x7e]+)$/.exec(supplied);
52
+ return match === null
53
+ ? { kind: "rejected" }
54
+ : { kind: "bearer", value: match[1] ?? "" };
55
+ },
56
+ });
31
57
  /**
32
58
  * Runs the private native endpoint behind one admitted browser listener.
33
59
  *
34
60
  * Hosts browser-facing Spine gateway routes independently from native server assembly.
35
61
  */
36
62
  export const browserServerImplementation = Object.freeze({
63
+ /**
64
+ * Starts a browser listener against the supplied native backend.
65
+ *
66
+ * @param native The native backend to expose through this browser host.
67
+ * @param input Browser configuration to validate before opening the listener.
68
+ * @returns The listening browser endpoint with coordinated native-backend cleanup.
69
+ */
37
70
  async open(native, input) {
38
71
  const running = BrowserServerValues.running(native);
39
72
  if (running !== undefined && !BrowserServerValues.isLoopback(running.host))
@@ -46,218 +79,47 @@ export const browserServerImplementation = Object.freeze({
46
79
  await BrowserServerValues.rollback(error, running === undefined ? [] : [() => running.close()]);
47
80
  throw error;
48
81
  }
49
- const origins = BrowserServer.origins(options.origins);
50
- const authRoutes = BrowserServer.authRoutes(options.authRoutes);
51
- const activeAuth = new Set();
52
- const maxActiveAuthRequests = options.maxActiveAuthRequests ?? 64;
53
- if (!Number.isSafeInteger(maxActiveAuthRequests) || maxActiveAuthRequests < 1)
54
- throw new Error("Browser maxActiveAuthRequests must be a positive safe integer.");
55
- let draining = false;
56
- const backendBaseUrls = native === undefined
57
- ? options.backend === undefined
58
- ? []
59
- : BrowserServer.backendUrls(BrowserServerValues.backendUrlsFor(options.backend))
60
- : Array.isArray(native)
61
- ? BrowserServer.backendUrls(native)
62
- : [
63
- typeof native === "string"
64
- ? native
65
- : BrowserServerValues.requiredRunning(running).baseUrl,
66
- ];
67
- const fixedNodes = backendBaseUrls.map((endpoint, index) => new ApplicationNode({ id: `fixed/${index.toString()}`, endpoint }));
68
- let dynamic;
69
- let stopDiscovery;
70
- const forwarder = (dynamic = new DynamicUnaryForwarder({
71
- create: async (node) => {
72
- const manager = options.dynamicManagerFactory?.(node) ??
73
- new Http2SessionManager(node.endpoint, undefined, node.tlsServerName === undefined ? undefined : { servername: node.tlsServerName });
74
- const client = new NativeSubscriptionCreator(createGrpcTransport({
75
- ...BrowserServer.dynamicTransportOptions(node),
76
- sessionManager: manager,
77
- }));
78
- return {
79
- forward: client.forward.bind(client),
80
- subscribe: client.subscribe.bind(client),
81
- activate: client.activate.bind(client),
82
- cancel: client.cancel.bind(client),
83
- dispose: client.dispose.bind(client),
84
- close: async () => {
85
- manager.abort();
86
- },
87
- };
88
- },
89
- }));
90
- const creator = new DynamicSubscriptionCreator(dynamic);
91
- const bindings = options.bindings ??
92
- new InMemorySubscriptionBindings({
93
- nextId: () => globalThis.crypto.randomUUID(),
94
- dispose: (definition, signal) => creator.cancel({ wire: definition }, signal),
95
- });
96
- const requests = BrowserServer.requests(options);
97
- const admission = options.publicAccess === true
98
- ? { publicAccess: true }
99
- : { sessions: options.sessions };
100
- const unary = new UnaryGateway({
101
- ...(options.registry === undefined ? {} : { registry: options.registry }),
102
- maxRequestBytes: 1_048_576,
103
- ...admission,
104
- authorize: options.authorize,
105
- contexts: options.contexts,
106
- clock: options.clock,
107
- forward: forwarder.forward.bind(forwarder),
108
- });
109
- const subscriptions = new SubscriptionGateway({
110
- bindings,
111
- ...admission,
112
- authorize: options.authorize,
113
- contexts: options.contexts,
114
- clock: options.clock,
115
- creator,
116
- });
117
- try {
118
- if (options.discovery !== undefined)
119
- stopDiscovery = await BrowserServerValues.watch(options.discovery, dynamic);
120
- else
121
- await dynamic.reconcile(fixedNodes);
122
- if (isDurableSubscriptionBindings(bindings))
123
- attachDurableSubscriptionCleanup(bindings, (definition, signal) => creator.cancel({ wire: definition }, signal));
124
- const durableBindings = bindings;
125
- if (durableBindings.recoverActive !== undefined) {
126
- const now = options.clock.now();
127
- const nowMs = Number(now.seconds.toString()) * 1_000 +
128
- Math.floor(Number(now.nanos.toString()) / 1_000_000);
129
- if (Number.isSafeInteger(nowMs))
130
- await durableBindings.recoverActive({
131
- nowMs,
132
- onDefinition: async (definition, whenExpires) => {
133
- await creator.rehydrate(definition);
134
- subscriptions.scheduleExpiry(whenExpires);
135
- },
136
- });
137
- }
138
- }
139
- catch (error) {
140
- const failures = [error];
141
- for (const cleanup of [
142
- () => subscriptions.close(),
143
- () => stopDiscovery?.(),
144
- () => dynamic.close(),
145
- () => running?.close(),
146
- ])
147
- try {
148
- await cleanup();
149
- }
150
- catch (cleanupError) {
151
- failures.push(cleanupError);
152
- }
153
- if (failures.length === 1)
154
- throw error;
155
- throw new AggregateError(failures, "Browser subscription recovery startup rollback failed.");
156
- }
157
- const services = createNativeGatewayServices({ unary, subscriptions, requests });
158
- const handler = connectNodeAdapter({
159
- routes: (router) => {
160
- router.service(AuthenticationService, services.authentication);
161
- router.service(CommandService, services.command);
162
- router.service(QueryService, services.query);
163
- router.service(SubscriptionService, services.subscription);
164
- },
165
- readMaxBytes: options.readMaxBytes,
166
- writeMaxBytes: options.writeMaxBytes,
167
- });
168
- const server = http.createServer((request, response) => {
169
- if (draining) {
170
- response.statusCode = 503;
171
- response.end();
172
- return;
173
- }
174
- const path = request.url ?? "";
175
- const auth = authRoutes.get(path);
176
- if (auth !== undefined && request.method === auth.method) {
177
- if (activeAuth.size >= maxActiveAuthRequests) {
178
- response.statusCode = 503;
179
- response.end();
180
- return;
181
- }
182
- void BrowserServer.dispatchAuth(request, response, auth, activeAuth, options.writeMaxBytes);
183
- return;
184
- }
185
- if (auth !== undefined) {
186
- const origin = request.headers.origin;
187
- if (request.method === "OPTIONS" && origin !== undefined && auth.origins.includes(origin)) {
188
- response.setHeader("access-control-allow-origin", origin);
189
- response.setHeader("access-control-allow-methods", `${auth.method},OPTIONS`);
190
- response.statusCode = 204;
191
- }
192
- else
193
- response.statusCode = request.method === "OPTIONS" ? 403 : 405;
194
- response.end();
195
- return;
196
- }
197
- const origin = request.headers.origin;
198
- if (origin === undefined || !origins.has(origin)) {
199
- response.statusCode = 403;
200
- response.end();
201
- return;
202
- }
203
- response.setHeader("access-control-allow-origin", origin);
204
- response.setHeader("access-control-allow-credentials", "true");
205
- response.setHeader("access-control-expose-headers", "grpc-status,grpc-message");
206
- response.setHeader("vary", "Origin");
207
- if (request.method === "OPTIONS") {
208
- response.setHeader("access-control-allow-methods", "POST,OPTIONS");
209
- response.setHeader("access-control-allow-headers", "authorization,content-type,connect-protocol-version,x-csrf-token");
210
- response.setHeader("access-control-max-age", "600");
211
- response.statusCode = 204;
212
- response.end();
213
- return;
214
- }
215
- handler(request, response);
216
- });
217
- let address;
218
- try {
219
- address = await BrowserServer.listen(server, options.host, options.port);
220
- }
221
- catch (error) {
222
- const cleanup = await Promise.allSettled([
223
- subscriptions.close(),
224
- stopDiscovery?.(),
225
- dynamic.close(),
226
- BrowserServer.closeListener(server),
227
- running?.close(),
228
- ]);
229
- const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
230
- if (failures.length > 0)
231
- throw new AggregateError([error, ...failures], "Server browser startup rollback failed.");
232
- throw error;
233
- }
234
- return new RunningBrowserServer(server, running, subscriptions, address, activeAuth, () => {
235
- draining = true;
236
- }, stopDiscovery, dynamic);
82
+ return BrowserServerValues.openWithOptions(native, running, options);
237
83
  },
84
+ /**
85
+ * Starts the browser host and registers it for process-level shutdown.
86
+ *
87
+ * @param nativeOrOptions The local Server to start, or options for a standalone browser gateway.
88
+ * @param suppliedOptions Browser configuration when a local Server is supplied separately.
89
+ * @returns The running host registered with the process coordinator.
90
+ */
238
91
  async run(nativeOrOptions, suppliedOptions) {
239
92
  const options = suppliedOptions ?? nativeOrOptions;
240
- if (nativeOrOptions instanceof Server) {
93
+ const combined = nativeOrOptions instanceof Server;
94
+ if (combined) {
241
95
  if (!serverBuilderAccess.isLoopback(nativeOrOptions))
242
96
  throw new Error("Combined browser server requires a loopback native listener.");
243
- BrowserServer.preflight("combined", options);
244
97
  }
245
- else
246
- BrowserServer.preflight("standalone", options);
247
- const native = nativeOrOptions instanceof Server ? await nativeOrOptions.start() : undefined;
248
- const running = native === undefined
249
- ? await BrowserServer.open(undefined, options)
250
- : await BrowserServer.open(native, options);
98
+ const prepared = BrowserServer.preflight(combined ? "combined" : "standalone", options);
99
+ const native = combined ? await nativeOrOptions.start() : undefined;
100
+ const running = await BrowserServerValues.openWithOptions(native, native, prepared);
251
101
  return ProcessServerCoordinator.add(running, native === undefined ? undefined : ServerEnvironment.instance(), undefined, () => undefined);
252
102
  },
103
+ /**
104
+ * Applies listener defaults and checks browser admission configuration before startup.
105
+ *
106
+ * @param mode Whether a native server is started locally or supplied as a separate backend.
107
+ * @param input Browser configuration to validate before opening the listener.
108
+ * @returns Normalized listener options after all preflight checks pass.
109
+ */
253
110
  preflight(mode, input) {
111
+ const admission = BrowserServerValues.snapshotAdmission(input);
112
+ const production = Environment.instance().type === EnvironmentType.Production;
113
+ BrowserServer.requireDurableBindings(input, production);
254
114
  const options = {
255
115
  ...input,
116
+ origins: Object.freeze([...input.origins]),
117
+ ...admission,
256
118
  host: input.host ?? "127.0.0.1",
257
119
  port: input.port ?? 0,
258
120
  readMaxBytes: input.readMaxBytes ?? 4_194_304,
259
121
  writeMaxBytes: input.writeMaxBytes ?? 4_194_304,
260
- production: Environment.instance().type === EnvironmentType.Production,
122
+ production,
261
123
  };
262
124
  if (!options.host.trim())
263
125
  throw new Error("Browser server host must not be blank.");
@@ -269,27 +131,22 @@ export const browserServerImplementation = Object.freeze({
269
131
  throw new Error("Browser server writeMaxBytes must be a positive integer.");
270
132
  if (mode === "standalone" && options.backend === undefined && options.discovery === undefined)
271
133
  throw new Error("Standalone browser server requires a backend or discovery.");
272
- BrowserServer.requireDurableBindings(options, options.production);
273
134
  BrowserServer.origins(options.origins);
274
- BrowserServer.authRoutes(options.authRoutes);
275
135
  const capacity = options.maxActiveAuthRequests ?? 64;
276
136
  if (!Number.isSafeInteger(capacity) || capacity < 1)
277
137
  throw new Error("Browser maxActiveAuthRequests must be a positive safe integer.");
278
138
  return options;
279
139
  },
140
+ /**
141
+ * Builds credential extraction and transport-fact adapters for browser requests.
142
+ *
143
+ * @param options Browser listener, admission, and backend configuration.
144
+ * @returns Adapters that the gateway uses to admit each incoming request.
145
+ */
280
146
  requests(options) {
147
+ void options;
281
148
  return {
282
- credential: (context) => {
283
- const headers = Object.fromEntries(context.requestHeader.entries());
284
- const extracted = options.cookies?.extract(headers);
285
- if (extracted !== undefined) {
286
- return extracted.kind === "rejected" ? { kind: "bearer", value: "" } : extracted;
287
- }
288
- const authorization = context.requestHeader.get("authorization") ?? "";
289
- return /^Bearer [^\s]+$/.test(authorization)
290
- ? { kind: "bearer", value: authorization.slice("Bearer ".length) }
291
- : undefined;
292
- },
149
+ credential: (context) => context.values?.get(admittedCredential),
293
150
  transport: (context) => {
294
151
  const origin = context.requestHeader.get("origin");
295
152
  return TransportFacts.from({
@@ -301,6 +158,12 @@ export const browserServerImplementation = Object.freeze({
301
158
  },
302
159
  };
303
160
  },
161
+ /**
162
+ * Validates a nonempty set of unique canonical HTTP(S) origins.
163
+ *
164
+ * @param values Browser origins to validate and deduplicate.
165
+ * @returns The validated origins for exact admission checks.
166
+ */
304
167
  origins(values) {
305
168
  const origins = new Set(values.map((value) => {
306
169
  let origin;
@@ -323,6 +186,12 @@ export const browserServerImplementation = Object.freeze({
323
186
  throw new Error("Server browser origins must be unique and non-empty.");
324
187
  return origins;
325
188
  },
189
+ /**
190
+ * Rejects backend URLs containing paths, credentials, queries, or fragments.
191
+ *
192
+ * @param value The backend origin to validate.
193
+ * @returns The original canonical backend origin.
194
+ */
326
195
  backendUrl(value) {
327
196
  let backend;
328
197
  try {
@@ -342,6 +211,12 @@ export const browserServerImplementation = Object.freeze({
342
211
  }
343
212
  return value;
344
213
  },
214
+ /**
215
+ * Validates a nonempty list of distinct native backend origins.
216
+ *
217
+ * @param values Native backend origins in routing order.
218
+ * @returns The validated backend origins in their configured order.
219
+ */
345
220
  backendUrls(values) {
346
221
  if (values.length < 1)
347
222
  throw new Error("Server browser backends must contain at least one origin.");
@@ -350,6 +225,12 @@ export const browserServerImplementation = Object.freeze({
350
225
  throw new Error("Server browser backends must be unique canonical HTTP(S) origins.");
351
226
  return urls;
352
227
  },
228
+ /**
229
+ * Selects the node endpoint and its configured TLS server name.
230
+ *
231
+ * @param node The application node whose native transport is being configured.
232
+ * @returns Transport settings preserving the node's TLS server-name override.
233
+ */
353
234
  dynamicTransportOptions(node) {
354
235
  return {
355
236
  baseUrl: node.endpoint,
@@ -358,6 +239,12 @@ export const browserServerImplementation = Object.freeze({
358
239
  : { nodeOptions: { servername: node.tlsServerName } }),
359
240
  };
360
241
  },
242
+ /**
243
+ * Checks admission ports and production subscription-persistence requirements.
244
+ *
245
+ * @param options Browser listener, admission, and backend configuration.
246
+ * @param production Whether production persistence requirements must be enforced.
247
+ */
361
248
  requireDurableBindings(options, production) {
362
249
  const supplied = options;
363
250
  const standalone = options.backend !== undefined || options.discovery !== undefined;
@@ -396,6 +283,12 @@ export const browserServerImplementation = Object.freeze({
396
283
  !bindings.namespace.trim()))
397
284
  throw new Error("Production standalone browser server requires named durable subscription bindings.");
398
285
  },
286
+ /**
287
+ * Validates authentication routes and rejects duplicate or reserved RPC paths.
288
+ *
289
+ * @param routes Optional HTTP authentication endpoints to validate and index.
290
+ * @returns Validated routes indexed by their exact HTTP path.
291
+ */
399
292
  authRoutes(routes) {
400
293
  const result = new Map();
401
294
  for (const route of routes ?? []) {
@@ -421,113 +314,49 @@ export const browserServerImplementation = Object.freeze({
421
314
  }
422
315
  return result;
423
316
  },
317
+ /**
318
+ * Executes an authentication route within its configured origin, size, and time bounds.
319
+ *
320
+ * @param request The incoming HTTP request.
321
+ * @param response The HTTP response to complete with the route outcome.
322
+ * @param route The matched authentication endpoint and its admission constraints.
323
+ * @param active Controllers tracked so shutdown can abort active authentication requests.
324
+ * @param writeMaxBytes The existing transport bound for the authentication response body.
325
+ * @returns Completion after the HTTP response and request cleanup settle.
326
+ */
424
327
  async dispatchAuth(request, response, route, active, writeMaxBytes) {
425
- const origin = request.headers.origin;
426
- if ((origin === undefined && route.allowMissingOrigin !== true) ||
427
- (origin !== undefined && !route.origins.includes(origin))) {
428
- response.statusCode = 403;
429
- response.end();
328
+ if (!AuthRouteScope.admit(request, response, route))
430
329
  return;
431
- }
432
- const length = request.headers["content-length"];
433
- if (length !== undefined && (!/^\d+$/.test(length) || Number(length) > route.maxRequestBytes)) {
434
- response.statusCode = 413;
435
- response.end();
436
- return;
437
- }
438
- const controller = new AbortController();
439
- active.add(controller);
440
- const timer = setTimeout(() => {
441
- controller.abort();
442
- request.resume();
443
- }, route.timeoutMs);
444
- const abort = () => {
445
- controller.abort();
446
- };
447
- request.once("aborted", abort);
448
- response.once("close", abort);
449
- let rejectAbort;
450
- const aborted = new Promise((_resolve, reject) => {
451
- rejectAbort = reject;
452
- });
453
- let responseReader;
454
- const onAbort = () => {
455
- rejectAbort(new Error("browser auth request aborted"));
456
- void responseReader?.cancel().catch(() => undefined);
457
- };
458
- controller.signal.addEventListener("abort", onAbort, { once: true });
330
+ const scope = new AuthRouteScope(request, response, route.timeoutMs, active);
459
331
  try {
460
- const requestChunks = [];
461
- let size = 0;
462
- const requestBody = request[Symbol.asyncIterator]();
463
- for (;;) {
464
- const next = await Promise.race([requestBody.next(), aborted]);
465
- if (next.done)
466
- break;
467
- const bytes = Buffer.from(next.value);
468
- size += bytes.byteLength;
469
- if (size > route.maxRequestBytes) {
470
- response.statusCode = 413;
471
- response.end();
472
- return;
473
- }
474
- requestChunks.push(bytes);
475
- }
332
+ const body = await scope.body(route.maxRequestBytes);
333
+ if (body === undefined)
334
+ return;
476
335
  const url = new URL(request.url ?? route.path, `http://${request.headers.host ?? "localhost"}`);
477
336
  const input = new Request(url, {
478
337
  method: route.method,
479
338
  headers: request.headers,
480
- ...(size === 0 ? {} : { body: Buffer.concat(requestChunks) }),
481
- signal: controller.signal,
339
+ ...(body.byteLength === 0 ? {} : { body: new Uint8Array(body) }),
340
+ signal: scope.signal,
482
341
  });
483
- const result = await Promise.race([route.onRequest(input, controller.signal), aborted]);
484
- if (controller.signal.aborted) {
485
- if (!response.writableEnded) {
486
- response.statusCode = 504;
487
- response.end();
488
- }
489
- return;
490
- }
491
- responseReader = result.body?.getReader();
492
- const responseChunks = [];
493
- let responseBytes = 0;
494
- if (responseReader !== undefined) {
495
- for (;;) {
496
- const next = await Promise.race([responseReader.read(), aborted]);
497
- if (next.done)
498
- break;
499
- responseBytes += next.value.byteLength;
500
- if (responseBytes > writeMaxBytes) {
501
- await responseReader.cancel().catch(() => undefined);
502
- response.statusCode = 413;
503
- response.end();
504
- return;
505
- }
506
- responseChunks.push(next.value);
507
- }
508
- }
509
- response.statusCode = result.status;
510
- result.headers.forEach((value, key) => response.setHeader(key, value));
511
- response.end(Buffer.concat(responseChunks));
342
+ const result = await scope.wait(Promise.resolve(route.onRequest(input, scope.signal)));
343
+ await scope.send(result, writeMaxBytes);
512
344
  }
513
345
  catch {
514
- if (!response.writableEnded) {
515
- if (controller.signal.aborted) {
516
- response.shouldKeepAlive = false;
517
- response.setHeader("connection", "close");
518
- }
519
- response.statusCode = controller.signal.aborted ? 504 : 500;
520
- response.end();
521
- }
346
+ scope.fail();
522
347
  }
523
348
  finally {
524
- clearTimeout(timer);
525
- request.off("aborted", abort);
526
- response.off("close", abort);
527
- controller.signal.removeEventListener("abort", onAbort);
528
- active.delete(controller);
349
+ scope.close();
529
350
  }
530
351
  },
352
+ /**
353
+ * Binds the HTTP listener and propagates startup errors.
354
+ *
355
+ * @param server The HTTP listener being started or stopped.
356
+ * @param host The interface address on which to bind the listener.
357
+ * @param port The requested listener port; zero requests an ephemeral port.
358
+ * @returns The actual bound address, including an allocated ephemeral port.
359
+ */
531
360
  listen(server, host, port) {
532
361
  return new Promise((resolve, reject) => {
533
362
  const onError = (error) => {
@@ -540,6 +369,12 @@ export const browserServerImplementation = Object.freeze({
540
369
  });
541
370
  });
542
371
  },
372
+ /**
373
+ * Stops accepting connections and drains the HTTP listener.
374
+ *
375
+ * @param server The HTTP listener being started or stopped.
376
+ * @returns Completion after the listener closes; rejects on a close error.
377
+ */
543
378
  closeListener(server) {
544
379
  return new Promise((resolve, reject) => {
545
380
  if (!server.listening) {
@@ -569,23 +404,856 @@ export const browserServerTestAccess = Object.freeze({
569
404
  ...browserServerImplementation,
570
405
  });
571
406
  const BrowserServer = browserServerImplementation;
407
+ /**
408
+ * Tracks one bounded authentication request through body and response streaming.
409
+ */
410
+ class AuthRouteScope {
411
+ request;
412
+ response;
413
+ active;
414
+ /**
415
+ * Checks route origin and declared body length before allocating request state.
416
+ * @param request Incoming HTTP request.
417
+ * @param response Response for rejected input.
418
+ * @param route Selected authentication route.
419
+ * @returns Whether the request may enter the route callback.
420
+ */
421
+ static admit(request, response, route) {
422
+ const origin = request.headers.origin;
423
+ if ((origin === undefined && route.allowMissingOrigin !== true) ||
424
+ (origin !== undefined && !route.origins.includes(origin))) {
425
+ response.statusCode = 403;
426
+ response.end();
427
+ return false;
428
+ }
429
+ const length = request.headers["content-length"];
430
+ if (length !== undefined && (!/^\d+$/.test(length) || Number(length) > route.maxRequestBytes)) {
431
+ response.statusCode = 413;
432
+ response.end();
433
+ return false;
434
+ }
435
+ return true;
436
+ }
437
+ #controller = new AbortController();
438
+ #aborted;
439
+ #timer;
440
+ #responseReader;
441
+ #abort = () => {
442
+ this.#controller.abort();
443
+ };
444
+ #onAbort;
445
+ /**
446
+ * Starts the route deadline and disconnect fences.
447
+ * @param request Incoming HTTP request.
448
+ * @param response Response to complete or abort.
449
+ * @param timeoutMs Configured route deadline in milliseconds.
450
+ * @param active Controllers drained when the host closes.
451
+ */
452
+ constructor(request, response, timeoutMs, active) {
453
+ this.request = request;
454
+ this.response = response;
455
+ this.active = active;
456
+ let rejectAbort;
457
+ this.#aborted = new Promise((_resolve, reject) => {
458
+ rejectAbort = reject;
459
+ });
460
+ this.#onAbort = () => {
461
+ rejectAbort(new Error("browser auth request aborted"));
462
+ void this.#responseReader?.cancel().catch(() => undefined);
463
+ };
464
+ active.add(this.#controller);
465
+ this.#timer = setTimeout(() => {
466
+ this.#controller.abort();
467
+ request.resume();
468
+ }, timeoutMs);
469
+ request.once("aborted", this.#abort);
470
+ response.once("close", this.#abort);
471
+ this.#controller.signal.addEventListener("abort", this.#onAbort, { once: true });
472
+ }
473
+ /**
474
+ * Exposes the signal passed to the configured route callback.
475
+ * @returns Signal that aborts on deadline, disconnect or host drain.
476
+ */
477
+ get signal() {
478
+ return this.#controller.signal;
479
+ }
480
+ /**
481
+ * Awaits one route step while allowing deadline and disconnect to stop it.
482
+ * @typeParam T Result returned by the pending route step.
483
+ * @param pending Body, callback, or response-read step.
484
+ * @returns The step's result when the request remains active.
485
+ */
486
+ async wait(pending) {
487
+ const result = await Promise.race([pending, this.#aborted]);
488
+ if (this.signal.aborted)
489
+ throw new Error("browser auth request aborted");
490
+ return result;
491
+ }
492
+ /**
493
+ * Reads the bounded route body before constructing a WHATWG request.
494
+ * @param limit Configured maximum request bytes.
495
+ * @returns Complete body, or undefined after a 413 response.
496
+ */
497
+ async body(limit) {
498
+ const chunks = [];
499
+ let size = 0;
500
+ const body = this.request[Symbol.asyncIterator]();
501
+ for (;;) {
502
+ const next = await this.wait(body.next());
503
+ if (next.done)
504
+ return Buffer.concat(chunks);
505
+ const bytes = Buffer.from(next.value);
506
+ size += bytes.byteLength;
507
+ if (size > limit) {
508
+ this.response.statusCode = 413;
509
+ this.response.end();
510
+ return undefined;
511
+ }
512
+ chunks.push(bytes);
513
+ }
514
+ }
515
+ /**
516
+ * Streams a bounded route response and preserves separate Set-Cookie fields.
517
+ * @param result Configured route callback result.
518
+ * @param limit Existing response-size transport bound.
519
+ * @returns Completion after the response is sent or rejected for size.
520
+ */
521
+ async send(result, limit) {
522
+ this.#requireActive();
523
+ this.#responseReader = result.body?.getReader();
524
+ const chunks = [];
525
+ let size = 0;
526
+ if (this.#responseReader !== undefined) {
527
+ for (;;) {
528
+ const next = await this.wait(this.#responseReader.read());
529
+ if (next.done)
530
+ break;
531
+ size += next.value.byteLength;
532
+ if (size > limit) {
533
+ await this.wait(this.#responseReader.cancel().catch(() => undefined));
534
+ this.#requireActive();
535
+ this.response.statusCode = 413;
536
+ this.response.end();
537
+ return;
538
+ }
539
+ chunks.push(next.value);
540
+ }
541
+ }
542
+ this.#requireActive();
543
+ this.response.statusCode = result.status;
544
+ this.#forwardHeaders(result.headers);
545
+ this.response.end(Buffer.concat(chunks));
546
+ }
547
+ /**
548
+ * Copies callback headers while retaining host CORS and separate cookie values.
549
+ * @param headers Callback response headers.
550
+ */
551
+ #forwardHeaders(headers) {
552
+ headers.forEach((value, key) => {
553
+ if (key === "vary") {
554
+ const values = value
555
+ .split(",")
556
+ .map((part) => part.trim())
557
+ .filter(Boolean);
558
+ this.response.setHeader("vary", [...new Set(["Origin", ...values])].join(", "));
559
+ }
560
+ else if (key !== "set-cookie" && !key.startsWith("access-control-"))
561
+ this.response.setHeader(key, value);
562
+ });
563
+ const cookies = headers.getSetCookie();
564
+ if (cookies.length > 0)
565
+ this.response.setHeader("set-cookie", cookies);
566
+ }
567
+ /**
568
+ * Rejects output after a deadline or disconnect aborts the route.
569
+ */
570
+ #requireActive() {
571
+ if (this.signal.aborted)
572
+ throw new Error("browser auth request aborted");
573
+ }
574
+ /**
575
+ * Completes a failed route without exposing partial callback output.
576
+ */
577
+ fail() {
578
+ if (this.response.writableEnded)
579
+ return;
580
+ if (this.signal.aborted) {
581
+ this.response.shouldKeepAlive = false;
582
+ this.response.setHeader("connection", "close");
583
+ }
584
+ this.response.statusCode = this.signal.aborted ? 504 : 500;
585
+ this.response.end();
586
+ }
587
+ /**
588
+ * Removes the deadline and active-request membership.
589
+ */
590
+ close() {
591
+ clearTimeout(this.#timer);
592
+ this.request.off("aborted", this.#abort);
593
+ this.response.off("close", this.#abort);
594
+ this.signal.removeEventListener("abort", this.#onAbort);
595
+ this.active.delete(this.#controller);
596
+ }
597
+ }
572
598
  const BrowserServerValues = Object.freeze({
599
+ /**
600
+ * Gets one unambiguous original Origin field for credentialed CORS.
601
+ * @param request Incoming Node request before header normalization.
602
+ * @returns The sole raw Origin value, when present exactly once.
603
+ */
604
+ rawOrigin(request) {
605
+ let origin;
606
+ for (let index = 0; index < request.rawHeaders.length; index += 2) {
607
+ if (request.rawHeaders[index]?.toLowerCase() !== "origin")
608
+ continue;
609
+ if (origin !== undefined)
610
+ return undefined;
611
+ origin = request.rawHeaders[index + 1];
612
+ }
613
+ return origin;
614
+ },
615
+ /**
616
+ * Applies the host's exact-origin credentialed CORS policy to every admitted response.
617
+ * @param response Node response receiving policy headers.
618
+ * @param origin Validated exact origin from the raw request.
619
+ * @param csrfNames Frozen active reader or route CSRF header names.
620
+ * @param method Configured route method or RPC POST method.
621
+ * @param rpc Whether gRPC status headers must be exposed.
622
+ */
623
+ cors(response, origin, csrfNames, method, rpc) {
624
+ response.setHeader("access-control-allow-origin", origin);
625
+ response.setHeader("access-control-allow-credentials", "true");
626
+ response.setHeader("access-control-allow-methods", `${method},OPTIONS`);
627
+ response.setHeader("access-control-allow-headers", [
628
+ ...new Set(["authorization", "x-csrf-token", ...connectCors.allowedHeaders, ...csrfNames].map((name) => name.toLowerCase())),
629
+ ].join(","));
630
+ if (rpc)
631
+ response.setHeader("access-control-expose-headers", connectCors.exposedHeaders.map((name) => name.toLowerCase()).join(","));
632
+ response.setHeader("access-control-max-age", "600");
633
+ response.setHeader("vary", "Origin");
634
+ },
635
+ /**
636
+ * Reads request-local raw facts inside Connect's deadline and disconnect signal.
637
+ * @param reader Configured browser credential reader.
638
+ * @returns Connect interceptor that transfers only the admitted credential to the Gateway.
639
+ */
640
+ credentialInterceptor(reader) {
641
+ return (next) => async (request) => {
642
+ const facts = credentialContext.getStore()?.facts;
643
+ if (facts === undefined)
644
+ throw new ConnectError("Browser credential facts are absent.", Code.Internal);
645
+ const credential = await BrowserServerValues.readCredential(reader, facts, request.signal);
646
+ request.contextValues.set(admittedCredential, credential);
647
+ return next(request);
648
+ };
649
+ },
650
+ /**
651
+ * Reads a credential under Connect cancellation and classifies terminal outcomes.
652
+ * @param reader Configured browser credential reader.
653
+ * @param facts Immutable raw headers, method, and request-local URL.
654
+ * @param signal Connect request signal including its protocol deadline.
655
+ * @returns Credential or absence before application handling.
656
+ */
657
+ async readCredential(reader, facts, signal) {
658
+ const result = await BrowserServerValues.awaitReader(reader, facts, signal);
659
+ if (signal.aborted)
660
+ throw BrowserServerValues.abortReason(signal);
661
+ if (result?.kind === "rejected")
662
+ throw new ConnectError("Browser credential rejected.", Code.Unauthenticated);
663
+ return result;
664
+ },
665
+ /**
666
+ * Awaits a reader until Connect aborts or reaches its deadline.
667
+ * @param reader Configured browser credential reader.
668
+ * @param facts Immutable raw request facts.
669
+ * @param signal Connect request signal.
670
+ * @returns Reader result before terminal classification.
671
+ */
672
+ async awaitReader(reader, facts, signal) {
673
+ if (signal.aborted)
674
+ throw BrowserServerValues.abortReason(signal);
675
+ const cancelled = new Error("browser request cancelled");
676
+ let onAbort;
677
+ const cancellation = new Promise((_resolve, reject) => {
678
+ onAbort = () => {
679
+ reject(cancelled);
680
+ };
681
+ });
682
+ signal.addEventListener("abort", onAbort, { once: true });
683
+ try {
684
+ return await Promise.race([
685
+ Promise.resolve().then(() => {
686
+ if (signal.aborted)
687
+ throw cancelled;
688
+ return reader.read(facts);
689
+ }),
690
+ cancellation,
691
+ ]);
692
+ }
693
+ catch (error) {
694
+ if (error === cancelled)
695
+ throw BrowserServerValues.abortReason(signal);
696
+ throw new ConnectError("Browser credential reader unavailable.", Code.Unavailable, undefined, undefined, error);
697
+ }
698
+ finally {
699
+ signal.removeEventListener("abort", onAbort);
700
+ }
701
+ },
702
+ /**
703
+ * Returns Connect's deadline reason or a classified request cancellation.
704
+ * @param signal Aborted Connect request signal.
705
+ * @returns Deadline or cancellation error for the current call.
706
+ */
707
+ abortReason(signal) {
708
+ return signal.reason instanceof ConnectError
709
+ ? signal.reason
710
+ : new ConnectError("Browser request cancelled.", Code.Canceled);
711
+ },
712
+ /**
713
+ * Validates and copies reader and route declarations before any startup await.
714
+ * @param input Untrusted browser configuration at the startup boundary.
715
+ * @returns One frozen route and admission snapshot.
716
+ */
717
+ snapshotAdmission(input) {
718
+ if (input.credentials !== undefined && input.cookies !== undefined)
719
+ throw new Error("Browser credentials and legacy cookies cannot both be configured.");
720
+ const reader = input.credentials ?? input.cookies ?? bearerCredentials;
721
+ const routes = BrowserServer.authRoutes(input.authRoutes);
722
+ const policy = BrowserAdmissionPolicy.create(reader.admission, routes);
723
+ const copied = new Map();
724
+ for (const [path, route] of routes) {
725
+ const admission = policy.routes.get(path);
726
+ if (admission === undefined)
727
+ throw new Error("Browser route admission snapshot is absent.");
728
+ copied.set(path, Object.freeze({
729
+ ...route,
730
+ origins: Object.freeze([...route.origins]),
731
+ admission,
732
+ }));
733
+ }
734
+ return {
735
+ credentialReader: reader,
736
+ admissionPolicy: policy,
737
+ authRouteMap: copied,
738
+ authRoutes: Object.freeze([...copied.values()]),
739
+ };
740
+ },
741
+ /**
742
+ * Builds the admitted browser listener from normalized options.
743
+ *
744
+ * @param native The native backend to expose through this browser host.
745
+ * @param running The local native listener to include in coordinated cleanup, when present.
746
+ * @param options Browser listener, admission, and backend configuration.
747
+ * @returns The listening host after membership and durable recovery are ready.
748
+ */
749
+ async openWithOptions(native, running, options) {
750
+ const activeAuth = new Set();
751
+ let draining = false;
752
+ const { dynamic, requests, unary, subscriptions, stopDiscovery } = await BrowserServerValues.readyGraph(native, running, options);
753
+ const server = BrowserServerValues.admittedListener(options, activeAuth, () => draining, unary, subscriptions, requests);
754
+ return BrowserServerValues.finishBrowser({
755
+ options,
756
+ running,
757
+ subscriptions,
758
+ stopDiscovery,
759
+ dynamic,
760
+ activeAuth,
761
+ server,
762
+ onDrain: () => {
763
+ draining = true;
764
+ },
765
+ });
766
+ },
767
+ /**
768
+ * Starts discovery and durable recovery after assembling the gateway graph.
769
+ *
770
+ * @param native The native backend to expose through this browser host.
771
+ * @param running The local native listener to include in coordinated cleanup, when present.
772
+ * @param options Browser listener, admission, and backend configuration.
773
+ * @returns Gateway collaborators and the discovery cleanup callback, when discovery is configured.
774
+ */
775
+ async readyGraph(native, running, options) {
776
+ const graph = BrowserServerValues.prepareBrowser(native, running, options);
777
+ const stopDiscovery = await BrowserServerValues.startMembership(options, running, graph.fixedNodes, graph.dynamic, graph.creator, graph.bindings, graph.subscriptions);
778
+ return { ...graph, stopDiscovery };
779
+ },
780
+ /**
781
+ * Applies configured browser origin and auth-route policy to the listener.
782
+ *
783
+ * @param options Browser listener, admission, and backend configuration.
784
+ * @param activeAuth Controllers tracked for cancellation during browser-host shutdown.
785
+ * @param draining Reports whether shutdown has stopped admission of new requests.
786
+ * @param unary The gateway that admits and forwards unary Spine requests.
787
+ * @param subscriptions The gateway managing admitted browser subscriptions.
788
+ * @param requests Adapters for extracting credentials and transport facts from HTTP headers.
789
+ * @returns An HTTP server configured with admission routes but not yet listening.
790
+ */
791
+ admittedListener(options, activeAuth, draining, unary, subscriptions, requests) {
792
+ const maxActiveAuthRequests = options.maxActiveAuthRequests ?? 64;
793
+ if (!Number.isSafeInteger(maxActiveAuthRequests) || maxActiveAuthRequests < 1)
794
+ throw new Error("Browser maxActiveAuthRequests must be a positive safe integer.");
795
+ return BrowserServerValues.createListener({
796
+ options,
797
+ origins: BrowserServer.origins(options.origins),
798
+ authRoutes: options.authRouteMap,
799
+ activeAuth,
800
+ maxActiveAuthRequests,
801
+ draining,
802
+ unary,
803
+ subscriptions,
804
+ requests,
805
+ });
806
+ },
807
+ /**
808
+ * Builds one native backend and the admission collaborators before discovery.
809
+ *
810
+ * @param native The native backend to expose through this browser host.
811
+ * @param running The local native listener to include in coordinated cleanup, when present.
812
+ * @param options Browser listener, admission, and backend configuration.
813
+ * @returns The backend, binding, and admission collaborators needed for startup.
814
+ */
815
+ prepareBrowser(native, running, options) {
816
+ const fixedNodes = BrowserServerValues.fixedNodes(native, running, options);
817
+ const dynamic = BrowserServerValues.dynamic(options);
818
+ const creator = new DynamicSubscriptionCreator(dynamic);
819
+ const bindings = BrowserServerValues.bindings(options, creator);
820
+ const { requests, unary, subscriptions } = BrowserServerValues.admission(options, dynamic, creator, bindings);
821
+ return { fixedNodes, dynamic, creator, bindings, requests, unary, subscriptions };
822
+ },
823
+ /**
824
+ * Returns durable bindings or constructs the local in-memory admission port.
825
+ *
826
+ * @param options Browser listener, admission, and backend configuration.
827
+ * @param creator The native subscription adapter used for creation, recovery, and cancellation.
828
+ * @returns The supplied binding store or a new process-local store with native cancellation.
829
+ */
830
+ bindings(options, creator) {
831
+ return (options.bindings ??
832
+ new InMemorySubscriptionBindings({
833
+ nextId: () => globalThis.crypto.randomUUID(),
834
+ dispose: (definition, signal) => creator.cancel({ wire: definition }, signal),
835
+ }));
836
+ },
837
+ /**
838
+ * Publishes one assembled listener after startup rollback is established.
839
+ *
840
+ * @param input Started browser resources and their coordinated shutdown callbacks.
841
+ * @returns The published browser endpoint with coordinated resource cleanup.
842
+ */
843
+ async finishBrowser(input) {
844
+ const address = await BrowserServerValues.listenStarted(input);
845
+ return new RunningBrowserServer(input.server, input.running, input.subscriptions, address, input.activeAuth, input.onDrain, input.stopDiscovery, input.dynamic);
846
+ },
847
+ /**
848
+ * Rolls back all started browser resources when listener bind fails.
849
+ *
850
+ * @param input Started browser resources and their coordinated shutdown callbacks.
851
+ * @returns The bound listener address; startup failures trigger resource cleanup.
852
+ */
853
+ async listenStarted(input) {
854
+ try {
855
+ return await BrowserServer.listen(input.server, input.options.host, input.options.port);
856
+ }
857
+ catch (error) {
858
+ const cleanup = await Promise.allSettled([
859
+ input.subscriptions.close(),
860
+ input.stopDiscovery?.(),
861
+ input.dynamic.close(),
862
+ BrowserServer.closeListener(input.server),
863
+ input.running?.close(),
864
+ ]);
865
+ const failures = cleanup.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
866
+ if (failures.length > 0)
867
+ throw new AggregateError([error, ...failures], "Server browser startup rollback failed.");
868
+ throw error;
869
+ }
870
+ },
871
+ /**
872
+ * Resolves fixed native backends without changing dynamic discovery behavior.
873
+ *
874
+ * @param native The native backend to expose through this browser host.
875
+ * @param running The local native listener to include in coordinated cleanup, when present.
876
+ * @param options Browser listener, admission, and backend configuration.
877
+ * @returns Configured fixed endpoints represented as application nodes.
878
+ */
879
+ fixedNodes(native, running, options) {
880
+ const urls = native === undefined
881
+ ? options.backend === undefined
882
+ ? []
883
+ : BrowserServer.backendUrls(BrowserServerValues.backendUrlsFor(options.backend))
884
+ : Array.isArray(native)
885
+ ? BrowserServer.backendUrls(native)
886
+ : [
887
+ typeof native === "string"
888
+ ? native
889
+ : BrowserServerValues.requiredRunning(running).baseUrl,
890
+ ];
891
+ return urls.map((endpoint, index) => new ApplicationNode({ id: `fixed/${index.toString()}`, endpoint }));
892
+ },
893
+ /**
894
+ * Creates the dynamically reconciled native transport adapter.
895
+ *
896
+ * @param options Browser listener, admission, and backend configuration.
897
+ * @returns A forwarder that creates native clients as node membership changes.
898
+ */
899
+ dynamic(options) {
900
+ return new DynamicUnaryForwarder({
901
+ create: (node) => BrowserServerValues.nativeClient(node, options),
902
+ });
903
+ },
904
+ /**
905
+ * Opens a native client with the configured reader proof producer.
906
+ *
907
+ * @param node The application node whose native transport is being configured.
908
+ * @param options Browser listener, admission, and backend configuration.
909
+ * @returns Native RPC operations and a callback that aborts their HTTP/2 session manager.
910
+ */
911
+ async nativeClient(node, options) {
912
+ const manager = options.dynamicManagerFactory?.(node) ??
913
+ new Http2SessionManager(node.endpoint, undefined, node.tlsServerName === undefined ? undefined : { servername: node.tlsServerName });
914
+ const client = new NativeSubscriptionCreator(createGrpcTransport({
915
+ ...BrowserServer.dynamicTransportOptions(node),
916
+ sessionManager: manager,
917
+ }), options.forwardingProof === undefined
918
+ ? undefined
919
+ : {
920
+ issuer: options.forwardingProof,
921
+ maxBytes: options.readMaxBytes,
922
+ });
923
+ return {
924
+ forward: client.forward.bind(client),
925
+ subscribe: client.subscribe.bind(client),
926
+ activate: client.activate.bind(client),
927
+ cancel: client.cancel.bind(client),
928
+ dispose: client.dispose.bind(client),
929
+ close: async () => {
930
+ manager.abort();
931
+ },
932
+ };
933
+ },
934
+ /**
935
+ * Creates unary and subscription admission around one reader-aware backend.
936
+ *
937
+ * @param options Browser listener, admission, and backend configuration.
938
+ * @param dynamic The native forwarder following current application-node membership.
939
+ * @param creator The native subscription adapter used for creation, recovery, and cancellation.
940
+ * @param bindings The store retaining browser-to-native subscription bindings.
941
+ * @returns Request adapters and gateways sharing the configured admission policy.
942
+ */
943
+ admission(options, dynamic, creator, bindings) {
944
+ const requests = BrowserServer.requests(options);
945
+ const admission = options.publicAccess === true
946
+ ? { publicAccess: true }
947
+ : { sessions: options.sessions };
948
+ const unary = new UnaryGateway({
949
+ ...(options.registry === undefined ? {} : { registry: options.registry }),
950
+ maxRequestBytes: 1_048_576,
951
+ ...admission,
952
+ authorize: options.authorize,
953
+ contexts: options.contexts,
954
+ clock: options.clock,
955
+ forward: dynamic.forward.bind(dynamic),
956
+ });
957
+ const subscriptions = new SubscriptionGateway({
958
+ bindings,
959
+ ...admission,
960
+ authorize: options.authorize,
961
+ contexts: options.contexts,
962
+ clock: options.clock,
963
+ creator,
964
+ });
965
+ return { requests, unary, subscriptions };
966
+ },
967
+ /**
968
+ * Starts discovery and rehydrates only durable subscriptions with reader identity.
969
+ *
970
+ * @param options Browser listener, admission, and backend configuration.
971
+ * @param running The local native listener to include in coordinated cleanup, when present.
972
+ * @param fixedNodes Backend nodes used when no discovery source is configured.
973
+ * @param dynamic The native forwarder following current application-node membership.
974
+ * @param creator The native subscription adapter used for creation, recovery, and cancellation.
975
+ * @param bindings The store retaining browser-to-native subscription bindings.
976
+ * @param subscriptions The gateway managing admitted browser subscriptions.
977
+ * @returns The discovery stop callback, or no callback for fixed membership.
978
+ */
979
+ async startMembership(options, running, fixedNodes, dynamic, creator, bindings, subscriptions) {
980
+ let stopDiscovery;
981
+ try {
982
+ if (options.discovery !== undefined)
983
+ stopDiscovery = await BrowserServerValues.watch(options.discovery, dynamic);
984
+ else
985
+ await dynamic.reconcile(fixedNodes);
986
+ if (isDurableSubscriptionBindings(bindings))
987
+ attachDurableSubscriptionCleanup(bindings, (definition, signal) => creator.cancel({ wire: definition }, signal));
988
+ await BrowserServerValues.recoverBindings(options, bindings, creator, subscriptions);
989
+ return stopDiscovery;
990
+ }
991
+ catch (error) {
992
+ return BrowserServerValues.rollbackMembership(error, running, subscriptions, stopDiscovery, dynamic);
993
+ }
994
+ },
995
+ /**
996
+ * Closes discovery, admission and native listeners after recovery failure.
997
+ *
998
+ * @param error The startup failure to preserve while cleanup is attempted.
999
+ * @param running The local native listener to include in coordinated cleanup, when present.
1000
+ * @param subscriptions The gateway managing admitted browser subscriptions.
1001
+ * @param stopDiscovery The callback that stops discovery updates, when discovery is configured.
1002
+ * @param dynamic The native forwarder following current application-node membership.
1003
+ * @returns No value; the startup failure is rethrown with any cleanup failures.
1004
+ */
1005
+ async rollbackMembership(error, running, subscriptions, stopDiscovery, dynamic) {
1006
+ const failures = [error];
1007
+ for (const cleanup of [
1008
+ () => subscriptions.close(),
1009
+ () => stopDiscovery?.(),
1010
+ () => dynamic.close(),
1011
+ () => running?.close(),
1012
+ ])
1013
+ try {
1014
+ await cleanup();
1015
+ }
1016
+ catch (cleanupError) {
1017
+ failures.push(cleanupError);
1018
+ }
1019
+ if (failures.length === 1)
1020
+ throw error;
1021
+ throw new AggregateError(failures, "Browser subscription recovery startup rollback failed.");
1022
+ },
1023
+ /**
1024
+ * Restores live reader-bound definitions after membership is ready.
1025
+ *
1026
+ * @param options Browser listener, admission, and backend configuration.
1027
+ * @param bindings The store retaining browser-to-native subscription bindings.
1028
+ * @param creator The native subscription adapter used for creation, recovery, and cancellation.
1029
+ * @param subscriptions The gateway managing admitted browser subscriptions.
1030
+ * @returns Completion after retained definitions are rehydrated and expiry is scheduled.
1031
+ */
1032
+ async recoverBindings(options, bindings, creator, subscriptions) {
1033
+ if (bindings.recoverActive === undefined)
1034
+ return;
1035
+ const now = options.clock.now();
1036
+ const nowMs = Number(now.seconds.toString()) * 1_000 + Math.floor(Number(now.nanos.toString()) / 1_000_000);
1037
+ if (!Number.isSafeInteger(nowMs))
1038
+ return;
1039
+ await bindings.recoverActive({
1040
+ nowMs,
1041
+ onDefinition: async (definition, whenExpires) => {
1042
+ await creator.rehydrate(definition);
1043
+ subscriptions.scheduleExpiry(whenExpires);
1044
+ },
1045
+ });
1046
+ },
1047
+ /**
1048
+ * Binds admitted gateway services to one HTTP listener.
1049
+ *
1050
+ * @param input Gateway services and HTTP routing policy for this listener.
1051
+ * @returns The unbound HTTP server serving the admitted Spine routes.
1052
+ */
1053
+ createListener(input) {
1054
+ const services = createNativeGatewayServices({
1055
+ unary: input.unary,
1056
+ subscriptions: input.subscriptions,
1057
+ requests: input.requests,
1058
+ });
1059
+ const handler = connectNodeAdapter({
1060
+ interceptors: [BrowserServerValues.credentialInterceptor(input.options.credentialReader)],
1061
+ routes: (router) => {
1062
+ router.service(AuthenticationService, services.authentication);
1063
+ router.service(CommandService, services.command);
1064
+ router.service(QueryService, services.query);
1065
+ router.service(SubscriptionService, services.subscription);
1066
+ },
1067
+ readMaxBytes: input.options.readMaxBytes,
1068
+ writeMaxBytes: input.options.writeMaxBytes,
1069
+ });
1070
+ const context = { ...input, handler };
1071
+ return http.createServer((request, response) => {
1072
+ void BrowserServerValues.routeRequest(request, response, context).catch(() => {
1073
+ if (!response.writableEnded) {
1074
+ response.statusCode = 500;
1075
+ response.end();
1076
+ }
1077
+ });
1078
+ });
1079
+ },
1080
+ /**
1081
+ * Applies drain, authentication and RPC routing to one HTTP request.
1082
+ *
1083
+ * @param request The incoming HTTP request.
1084
+ * @param response The HTTP response to complete with the route outcome.
1085
+ * @param context Listener admission policy, active requests, and the RPC routing callback.
1086
+ * @returns Completion after admission or dispatch to the selected route.
1087
+ */
1088
+ async routeRequest(request, response, context) {
1089
+ if (context.draining()) {
1090
+ response.statusCode = 503;
1091
+ response.end();
1092
+ return;
1093
+ }
1094
+ const url = BrowserServerValues.requestUrl(request);
1095
+ if (url === undefined) {
1096
+ response.statusCode = 400;
1097
+ response.end();
1098
+ return;
1099
+ }
1100
+ const auth = context.authRoutes.get(url.pathname);
1101
+ const active = auth?.admission ?? context.options.admissionPolicy.reader;
1102
+ BrowserServerValues.requestCors(request, response, context, auth, active.csrfHeaderNames);
1103
+ const inspected = context.options.admissionPolicy.inspect(request, active);
1104
+ if (inspected.kind === "rejected") {
1105
+ response.statusCode = 400;
1106
+ response.end();
1107
+ return;
1108
+ }
1109
+ if (auth !== undefined) {
1110
+ BrowserServerValues.authRequest(request, response, auth, context);
1111
+ return;
1112
+ }
1113
+ BrowserServerValues.readRpc(request, response, context, inspected.headers, url);
1114
+ },
1115
+ /**
1116
+ * Applies CORS only for one allowed raw Origin and the selected route or reader.
1117
+ * @param request Incoming request before normalization.
1118
+ * @param response Response receiving host policy.
1119
+ * @param context Listener origins and active reader.
1120
+ * @param auth Selected authentication route, if any.
1121
+ * @param csrfNames Frozen CSRF names from the active declaration.
1122
+ */
1123
+ requestCors(request, response, context, auth, csrfNames) {
1124
+ const origin = BrowserServerValues.rawOrigin(request);
1125
+ if (origin === undefined ||
1126
+ (auth === undefined ? !context.origins.has(origin) : !auth.origins.includes(origin)))
1127
+ return;
1128
+ BrowserServerValues.cors(response, origin, csrfNames, auth?.method ?? "POST", auth === undefined);
1129
+ },
1130
+ /**
1131
+ * Parses one request target before route selection and reader invocation.
1132
+ * @param request Incoming HTTP request.
1133
+ * @returns Request-local URL, or undefined for an invalid target.
1134
+ */
1135
+ requestUrl(request) {
1136
+ try {
1137
+ return new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
1138
+ }
1139
+ catch {
1140
+ return undefined;
1141
+ }
1142
+ },
1143
+ /**
1144
+ * Dispatches immutable raw facts to Connect for deadline-bound credential admission.
1145
+ * @param request Incoming HTTP request.
1146
+ * @param response Response for terminal rejection or forwarding.
1147
+ * @param context Frozen host policy and gateway handler.
1148
+ * @param headers Immutable duplicate-preserving raw fields.
1149
+ * @param url Selected request URL before the reader receives its copy.
1150
+ */
1151
+ readRpc(request, response, context, headers, url) {
1152
+ if (!context.origins.has(request.headers.origin ?? "")) {
1153
+ response.statusCode = 403;
1154
+ response.end();
1155
+ return;
1156
+ }
1157
+ credentialContext.run({
1158
+ facts: {
1159
+ headers,
1160
+ method: request.method ?? "GET",
1161
+ url: new URL(url),
1162
+ },
1163
+ }, () => {
1164
+ BrowserServerValues.rpcRequest(request, response, context);
1165
+ });
1166
+ },
1167
+ /**
1168
+ * Handles the configured browser authentication route and its preflight.
1169
+ *
1170
+ * @param request The incoming HTTP request.
1171
+ * @param response The HTTP response to complete with the route outcome.
1172
+ * @param auth The authentication route matched by the request path.
1173
+ * @param context Listener admission policy, active requests, and the RPC routing callback.
1174
+ */
1175
+ authRequest(request, response, auth, context) {
1176
+ if (request.method === auth.method) {
1177
+ if (context.activeAuth.size >= context.maxActiveAuthRequests) {
1178
+ response.statusCode = 503;
1179
+ response.end();
1180
+ return;
1181
+ }
1182
+ void BrowserServer.dispatchAuth(request, response, auth, context.activeAuth, context.options.writeMaxBytes);
1183
+ return;
1184
+ }
1185
+ const origin = request.headers.origin;
1186
+ if (request.method === "OPTIONS" && origin !== undefined && auth.origins.includes(origin)) {
1187
+ response.statusCode = 204;
1188
+ }
1189
+ else
1190
+ response.statusCode = request.method === "OPTIONS" ? 403 : 405;
1191
+ response.end();
1192
+ },
1193
+ /**
1194
+ * Checks browser origin admission before a Connect or gRPC request.
1195
+ *
1196
+ * @param request The incoming HTTP request.
1197
+ * @param response The HTTP response to complete with the route outcome.
1198
+ * @param context Listener admission policy, active requests, and the RPC routing callback.
1199
+ */
1200
+ rpcRequest(request, response, context) {
1201
+ const origin = request.headers.origin;
1202
+ if (origin === undefined || !context.origins.has(origin)) {
1203
+ response.statusCode = 403;
1204
+ response.end();
1205
+ return;
1206
+ }
1207
+ BrowserServerValues.cors(response, origin, context.options.admissionPolicy.reader.csrfHeaderNames, "POST", true);
1208
+ if (request.method === "OPTIONS") {
1209
+ response.statusCode = 204;
1210
+ response.end();
1211
+ return;
1212
+ }
1213
+ context.handler(request, response);
1214
+ },
1215
+ /**
1216
+ * Subscribes the native forwarder to application-node discovery changes.
1217
+ *
1218
+ * @param source The source of application-node membership changes.
1219
+ * @param forwarder The native forwarder to reconcile when membership changes.
1220
+ * @returns The callback that stops watching node discovery.
1221
+ */
573
1222
  async watch(source, forwarder) {
574
1223
  return await source.watch((nodes) => {
575
1224
  void forwarder.reconcile(nodes);
576
1225
  });
577
1226
  },
1227
+ /**
1228
+ * Returns the local native listener or rejects an absent backend.
1229
+ *
1230
+ * @param value The possibly absent local native listener.
1231
+ * @returns The supplied listener, or throws when it is absent.
1232
+ */
578
1233
  requiredRunning(value) {
579
1234
  if (value === undefined)
580
1235
  throw new Error("Browser server local backend is absent.");
581
1236
  return value;
582
1237
  },
1238
+ /**
1239
+ * Checks IPv4 loopback addresses and the supported IPv6 loopback forms.
1240
+ *
1241
+ * @param host The address to test for local loopback membership.
1242
+ * @returns Whether the address denotes a supported loopback interface.
1243
+ */
583
1244
  isLoopback(host) {
584
1245
  if (host === "::1" || host === "::ffff:127.0.0.1")
585
1246
  return true;
586
1247
  const match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(host);
587
1248
  return match?.slice(1).every((part) => Number(part) <= 255) === true;
588
1249
  },
1250
+ /**
1251
+ * Calls every supplied startup cleanup and preserves any cleanup failures.
1252
+ *
1253
+ * @param error The startup failure to preserve while cleanup is attempted.
1254
+ * @param cleanups Startup cleanup callbacks to attempt in the supplied order.
1255
+ * @returns No value; the original failure or an aggregate of startup and cleanup failures is thrown.
1256
+ */
589
1257
  async rollback(error, cleanups) {
590
1258
  const failures = [error];
591
1259
  for (const cleanup of cleanups)
@@ -599,17 +1267,35 @@ const BrowserServerValues = Object.freeze({
599
1267
  throw error;
600
1268
  throw new AggregateError(failures, "Browser server startup rollback failed.");
601
1269
  },
1270
+ /**
1271
+ * Returns the first native subscription client or rejects an empty configuration.
1272
+ *
1273
+ * @param values Native subscription clients in configured order.
1274
+ * @returns The first configured client, or throws when none exists.
1275
+ */
602
1276
  firstCreator(values) {
603
1277
  const creator = values[0];
604
1278
  if (creator === undefined)
605
1279
  throw new Error("Browser server backend is absent.");
606
1280
  return creator;
607
1281
  },
1282
+ /**
1283
+ * Returns an already running native listener from the backend configuration.
1284
+ *
1285
+ * @param native The native backend to expose through this browser host.
1286
+ * @returns The supplied running listener, or undefined for URL-based or absent backends.
1287
+ */
608
1288
  running(native) {
609
1289
  return native === undefined || typeof native === "string" || Array.isArray(native)
610
1290
  ? undefined
611
1291
  : native;
612
1292
  },
1293
+ /**
1294
+ * Reads exactly one of the singular or plural backend URL options.
1295
+ *
1296
+ * @param backend The singular or plural backend URL configuration to inspect.
1297
+ * @returns The configured backend origins without applying URL validation.
1298
+ */
613
1299
  backendUrlsFor(backend) {
614
1300
  const source = backend;
615
1301
  if (typeof source.baseUrl === "string" && source.baseUrls === undefined)
@@ -623,6 +1309,9 @@ const BrowserServerValues = Object.freeze({
623
1309
  throw new Error("Server browser backend must configure exactly one of baseUrl or baseUrls.");
624
1310
  },
625
1311
  });
1312
+ /**
1313
+ * Closes the browser listener, discovery, admission and native backend together.
1314
+ */
626
1315
  class RunningBrowserServer {
627
1316
  #server;
628
1317
  #subscriptions;
@@ -641,6 +1330,18 @@ class RunningBrowserServer {
641
1330
  #discoveryStopped = false;
642
1331
  #dynamicClosed = false;
643
1332
  #nativeClosed = false;
1333
+ /**
1334
+ * Stores the running browser resources and exposes their bound address.
1335
+ *
1336
+ * @param server The HTTP listener being started or stopped.
1337
+ * @param native The native backend to expose through this browser host.
1338
+ * @param subscriptions The gateway managing admitted browser subscriptions.
1339
+ * @param address The actual address returned when the browser listener bound successfully.
1340
+ * @param activeAuth Controllers tracked for cancellation during browser-host shutdown.
1341
+ * @param onDrain Stops admission of new browser requests before resources are closed.
1342
+ * @param stopDiscovery The callback that stops discovery updates, when discovery is configured.
1343
+ * @param dynamic The native forwarder following current application-node membership.
1344
+ */
644
1345
  constructor(server, native, subscriptions, address, activeAuth, onDrain, stopDiscovery, dynamic) {
645
1346
  this.#server = server;
646
1347
  this.#native = native;
@@ -654,6 +1355,11 @@ class RunningBrowserServer {
654
1355
  const host = this.host.includes(":") && !this.host.startsWith("[") ? `[${this.host}]` : this.host;
655
1356
  this.baseUrl = `http://${host}:${this.port.toString()}`;
656
1357
  }
1358
+ /**
1359
+ * Returns a shared promise for concurrent shutdown attempts and permits retry after a failed attempt.
1360
+ *
1361
+ * @returns The shared shutdown attempt, which rejects if any cleanup phase fails.
1362
+ */
657
1363
  close() {
658
1364
  this.#closed ??= this.#closeOnce().catch((error) => {
659
1365
  this.#closed = undefined;
@@ -661,6 +1367,11 @@ class RunningBrowserServer {
661
1367
  });
662
1368
  return this.#closed;
663
1369
  }
1370
+ /**
1371
+ * Stops admission and attempts each unfinished shutdown phase.
1372
+ *
1373
+ * @returns Completion after all unfinished phases are attempted; rejects with collected failures.
1374
+ */
664
1375
  async #closeOnce() {
665
1376
  this.#onDrain();
666
1377
  for (const controller of this.#activeAuth)
@@ -690,6 +1401,14 @@ class RunningBrowserServer {
690
1401
  throw primary;
691
1402
  }
692
1403
  }
1404
+ /**
1405
+ * Marks successful shutdown work and collects failures without stopping later phases.
1406
+ *
1407
+ * @param operation An unfinished shutdown operation, or undefined when there is nothing to await.
1408
+ * @param onSuccess Records completion so a later shutdown retry skips this phase.
1409
+ * @param failures The shared collection receiving failures from shutdown phases.
1410
+ * @returns Completion after success is recorded or the failure is collected.
1411
+ */
693
1412
  async #phase(operation, onSuccess, failures) {
694
1413
  if (operation === undefined)
695
1414
  return;
@@ -701,6 +1420,11 @@ class RunningBrowserServer {
701
1420
  failures.push(error);
702
1421
  }
703
1422
  }
1423
+ /**
1424
+ * Returns the in-flight listener shutdown and resets it after settlement.
1425
+ *
1426
+ * @returns The current listener-close attempt or a newly started attempt.
1427
+ */
704
1428
  #closeListenerPhase() {
705
1429
  const current = this.#listenerClose;
706
1430
  if (current !== undefined)