@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.
- package/README.md +104 -16
- package/REFERENCE.md +486 -57
- package/dist/agent/agent-history.d.ts +19 -3
- package/dist/agent/agent-history.d.ts.map +1 -1
- package/dist/agent/agent-history.js +41 -10
- package/dist/agent/agent-history.js.map +1 -1
- package/dist/agent/agent-live-ai-runtime.d.ts +1 -1
- package/dist/agent/agent-live-ai-runtime.js +1 -1
- package/dist/agent/agent-live-budget.d.ts +1 -1
- package/dist/agent/agent-live-budget.js +1 -1
- package/dist/agent/agent-live-read-runtime.d.ts +13 -2
- package/dist/agent/agent-live-read-runtime.d.ts.map +1 -1
- package/dist/agent/agent-live-read-runtime.js +6 -5
- package/dist/agent/agent-live-read-runtime.js.map +1 -1
- package/dist/browser/browser-admission.d.ts +47 -0
- package/dist/browser/browser-admission.d.ts.map +1 -0
- package/dist/browser/browser-admission.js +200 -0
- package/dist/browser/browser-admission.js.map +1 -0
- package/dist/browser/browser-server.d.ts +171 -1
- package/dist/browser/browser-server.d.ts.map +1 -1
- package/dist/browser/browser-server.js +1027 -303
- package/dist/browser/browser-server.js.map +1 -1
- package/dist/browser/durable-subscription-bindings.d.ts +5 -0
- package/dist/browser/durable-subscription-bindings.d.ts.map +1 -1
- package/dist/browser/durable-subscription-bindings.js +226 -38
- package/dist/browser/durable-subscription-bindings.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/options.d.ts +53 -2
- package/dist/browser/options.d.ts.map +1 -1
- package/dist/bus/command-bus.d.ts +9 -1
- package/dist/bus/command-bus.d.ts.map +1 -1
- package/dist/bus/command-bus.js +75 -10
- package/dist/bus/command-bus.js.map +1 -1
- package/dist/bus/command-errors.d.ts +20 -4
- package/dist/bus/command-errors.d.ts.map +1 -1
- package/dist/bus/command-errors.js +47 -7
- package/dist/bus/command-errors.js.map +1 -1
- package/dist/bus/event-bus.d.ts +9 -1
- package/dist/bus/event-bus.d.ts.map +1 -1
- package/dist/bus/event-bus.js +136 -9
- package/dist/bus/event-bus.js.map +1 -1
- package/dist/context/bounded-context.d.ts +59 -39
- package/dist/context/bounded-context.d.ts.map +1 -1
- package/dist/context/bounded-context.js +277 -262
- package/dist/context/bounded-context.js.map +1 -1
- package/dist/context/entity-inbox.d.ts +12 -17
- package/dist/context/entity-inbox.d.ts.map +1 -1
- package/dist/context/entity-inbox.js +106 -119
- package/dist/context/entity-inbox.js.map +1 -1
- package/dist/context/local-inbox-handoff.d.ts +13 -13
- package/dist/context/local-inbox-handoff.d.ts.map +1 -1
- package/dist/context/local-inbox-handoff.js +60 -32
- package/dist/context/local-inbox-handoff.js.map +1 -1
- package/dist/context/persistent-tenant-index.d.ts +81 -0
- package/dist/context/persistent-tenant-index.d.ts.map +1 -0
- package/dist/context/persistent-tenant-index.js +270 -0
- package/dist/context/persistent-tenant-index.js.map +1 -0
- package/dist/context/projection-handoff.d.ts +9 -9
- package/dist/context/projection-handoff.d.ts.map +1 -1
- package/dist/context/projection-handoff.js +31 -23
- package/dist/context/projection-handoff.js.map +1 -1
- package/dist/context/tenant-index.d.ts +7 -6
- package/dist/context/tenant-index.d.ts.map +1 -1
- package/dist/context/tenant-index.js +2 -188
- package/dist/context/tenant-index.js.map +1 -1
- package/dist/context/tenant-unknown-error.d.ts +2 -2
- package/dist/context/tenant-unknown-error.d.ts.map +1 -1
- package/dist/context/tenant-unknown-error.js +4 -1
- package/dist/context/tenant-unknown-error.js.map +1 -1
- package/dist/delivery/delivery-supervisor.d.ts.map +1 -1
- package/dist/delivery/delivery-supervisor.js +12 -3
- package/dist/delivery/delivery-supervisor.js.map +1 -1
- package/dist/delivery/delivery.d.ts +4 -3
- package/dist/delivery/delivery.d.ts.map +1 -1
- package/dist/delivery/delivery.js +82 -34
- package/dist/delivery/delivery.js.map +1 -1
- package/dist/delivery/reception-observation.d.ts +3 -3
- package/dist/delivery/reception-observation.js +2 -2
- package/dist/entity/entity.d.ts +59 -0
- package/dist/entity/entity.d.ts.map +1 -1
- package/dist/entity/entity.js +60 -0
- package/dist/entity/entity.js.map +1 -1
- package/dist/handler/app-query-handler.d.ts +60 -0
- package/dist/handler/app-query-handler.d.ts.map +1 -0
- package/dist/handler/app-query-handler.js +23 -0
- package/dist/handler/app-query-handler.js.map +1 -0
- package/dist/handler/generated-registry-discovery.d.ts +6 -0
- package/dist/handler/generated-registry-discovery.d.ts.map +1 -1
- package/dist/handler/generated-registry-discovery.js +71 -2
- package/dist/handler/generated-registry-discovery.js.map +1 -1
- package/dist/handler/handler-decorators.d.ts +8 -29
- package/dist/handler/handler-decorators.d.ts.map +1 -1
- package/dist/handler/handler-decorators.js +33 -210
- package/dist/handler/handler-decorators.js.map +1 -1
- package/dist/handler/spine-metadata-inventory.d.ts +99 -0
- package/dist/handler/spine-metadata-inventory.d.ts.map +1 -0
- package/dist/handler/spine-metadata-inventory.js +162 -0
- package/dist/handler/spine-metadata-inventory.js.map +1 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/repository/command-routing.d.ts +1 -1
- package/dist/repository/command-routing.js +1 -1
- package/dist/repository/event-routing.d.ts +1 -1
- package/dist/repository/event-routing.js +1 -1
- package/dist/repository/repository.d.ts +61 -26
- package/dist/repository/repository.d.ts.map +1 -1
- package/dist/repository/repository.js +612 -306
- package/dist/repository/repository.js.map +1 -1
- package/dist/repository/state-update-routing.d.ts +1 -1
- package/dist/repository/state-update-routing.js +1 -1
- package/dist/runtime/signal-publisher.d.ts +23 -8
- package/dist/runtime/signal-publisher.d.ts.map +1 -1
- package/dist/runtime/signal-publisher.js +11 -8
- package/dist/runtime/signal-publisher.js.map +1 -1
- package/dist/server/managed-server-application.d.ts +12 -11
- package/dist/server/managed-server-application.d.ts.map +1 -1
- package/dist/server/managed-server-application.js +41 -25
- package/dist/server/managed-server-application.js.map +1 -1
- package/dist/server/node-coordinator.d.ts +18 -5
- package/dist/server/node-coordinator.d.ts.map +1 -1
- package/dist/server/node-coordinator.js +744 -108
- package/dist/server/node-coordinator.js.map +1 -1
- package/dist/server/reserved-spine-rpc-paths.json +1 -0
- package/dist/server/server-environment.d.ts +23 -5
- package/dist/server/server-environment.d.ts.map +1 -1
- package/dist/server/server-environment.js +89 -24
- package/dist/server/server-environment.js.map +1 -1
- package/dist/server/server.d.ts +45 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +112 -3
- package/dist/server/server.js.map +1 -1
- package/dist/services/app-query-execution.d.ts +53 -0
- package/dist/services/app-query-execution.d.ts.map +1 -0
- package/dist/services/app-query-execution.js +408 -0
- package/dist/services/app-query-execution.js.map +1 -0
- package/dist/services/native-operation.d.ts +34 -0
- package/dist/services/native-operation.d.ts.map +1 -0
- package/dist/services/native-operation.js +77 -0
- package/dist/services/native-operation.js.map +1 -0
- package/dist/services/query-reader.d.ts +32 -7
- package/dist/services/query-reader.d.ts.map +1 -1
- package/dist/services/query-reader.js +22 -2
- package/dist/services/query-reader.js.map +1 -1
- package/dist/services/read-request-status.d.ts +65 -0
- package/dist/services/read-request-status.d.ts.map +1 -0
- package/dist/services/read-request-status.js +151 -0
- package/dist/services/read-request-status.js.map +1 -0
- package/dist/services/spine-services.d.ts +42 -7
- package/dist/services/spine-services.d.ts.map +1 -1
- package/dist/services/spine-services.js +1005 -175
- package/dist/services/spine-services.js.map +1 -1
- package/dist/services/trusted-reader-ingress.d.ts +110 -0
- package/dist/services/trusted-reader-ingress.d.ts.map +1 -0
- package/dist/services/trusted-reader-ingress.js +102 -0
- package/dist/services/trusted-reader-ingress.js.map +1 -0
- package/dist/spi/handler-registry.d.ts +2 -0
- package/dist/spi/handler-registry.d.ts.map +1 -1
- package/dist/spi/handler-registry.js +1 -1
- package/dist/spi/handler-registry.js.map +1 -1
- package/dist/stand/read-side-filter-engine.d.ts +46 -0
- package/dist/stand/read-side-filter-engine.d.ts.map +1 -0
- package/dist/stand/read-side-filter-engine.js +356 -0
- package/dist/stand/read-side-filter-engine.js.map +1 -0
- package/dist/stand/read-side-filter.d.ts +247 -0
- package/dist/stand/read-side-filter.d.ts.map +1 -0
- package/dist/stand/read-side-filter.js +39 -0
- package/dist/stand/read-side-filter.js.map +1 -0
- package/dist/stand/read-side-message.d.ts +66 -0
- package/dist/stand/read-side-message.d.ts.map +1 -0
- package/dist/stand/read-side-message.js +167 -0
- package/dist/stand/read-side-message.js.map +1 -0
- package/dist/stand/stand-direct-delivery.d.ts +161 -0
- package/dist/stand/stand-direct-delivery.d.ts.map +1 -0
- package/dist/stand/stand-direct-delivery.js +210 -0
- package/dist/stand/stand-direct-delivery.js.map +1 -0
- package/dist/stand/stand.d.ts +81 -43
- package/dist/stand/stand.d.ts.map +1 -1
- package/dist/stand/stand.js +411 -55
- package/dist/stand/stand.js.map +1 -1
- package/dist/stand/subscription-observer.d.ts.map +1 -1
- package/dist/stand/subscription-observer.js +103 -0
- package/dist/stand/subscription-observer.js.map +1 -1
- package/dist/stand/subscription-read-filters.d.ts +40 -0
- package/dist/stand/subscription-read-filters.d.ts.map +1 -0
- package/dist/stand/subscription-read-filters.js +91 -0
- package/dist/stand/subscription-read-filters.js.map +1 -0
- package/dist/stand/subscription-reader-binding.d.ts +20 -0
- package/dist/stand/subscription-reader-binding.d.ts.map +1 -0
- package/dist/stand/subscription-reader-binding.js +56 -0
- package/dist/stand/subscription-reader-binding.js.map +1 -0
- package/dist/stand/subscription-records.d.ts +30 -3
- package/dist/stand/subscription-records.d.ts.map +1 -1
- package/dist/stand/subscription-records.js +61 -16
- package/dist/stand/subscription-records.js.map +1 -1
- package/dist/stand/subscription-registry.d.ts +49 -14
- package/dist/stand/subscription-registry.d.ts.map +1 -1
- package/dist/stand/subscription-registry.js +150 -31
- package/dist/stand/subscription-registry.js.map +1 -1
- package/dist/stand/subscription-runtime.d.ts +7 -2
- package/dist/stand/subscription-runtime.d.ts.map +1 -1
- package/dist/stand/subscription-runtime.js +131 -25
- package/dist/stand/subscription-runtime.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
...(
|
|
481
|
-
signal:
|
|
339
|
+
...(body.byteLength === 0 ? {} : { body: new Uint8Array(body) }),
|
|
340
|
+
signal: scope.signal,
|
|
482
341
|
});
|
|
483
|
-
const result = await Promise.
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|