@zlink-systems/framework 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/contracts/Configuration/Builders.d.ts +2 -0
  3. package/dist/contracts/Configuration/EndpointNotation.d.ts +3 -3
  4. package/dist/contracts/Configuration/EndpointNotation.js +24 -7
  5. package/dist/contracts/Configuration/FanoutTopic.d.ts +2 -0
  6. package/dist/contracts/Configuration/FanoutTopic.js +14 -0
  7. package/dist/contracts/Configuration/RegistrationBuilders.js +13 -0
  8. package/dist/contracts/Configuration/RegistrationTypes.d.ts +2 -0
  9. package/dist/contracts/Configuration/RegistrationValidators.js +19 -14
  10. package/dist/runtime/backend/contracts/index.d.ts +1 -0
  11. package/dist/runtime/backend/node/node-backend-adapter-support.js +9 -1
  12. package/dist/runtime/backend/node/node-socket-backend-adapter.js +6 -0
  13. package/dist/runtime/channels/channel-clients.js +2 -0
  14. package/dist/runtime/channels/channel-socket-registry.js +16 -2
  15. package/dist/runtime/channels/fanout-service-wire.d.ts +2 -2
  16. package/dist/runtime/channels/fanout-service-wire.js +5 -10
  17. package/dist/runtime/locations/in-memory-authority-store.js +9 -1
  18. package/dist/runtime/locations/location-store-repository.js +61 -3
  19. package/dist/runtime/streams/index.d.ts +1 -1
  20. package/dist/runtime/streams/index.js +7 -0
  21. package/package.json +3 -3
  22. package/src/contracts/Configuration/Builders.ts +2 -0
  23. package/src/contracts/Configuration/EndpointNotation.ts +23 -6
  24. package/src/contracts/Configuration/FanoutTopic.ts +12 -0
  25. package/src/contracts/Configuration/RegistrationBuilders.ts +17 -0
  26. package/src/contracts/Configuration/RegistrationTypes.ts +2 -0
  27. package/src/contracts/Configuration/RegistrationValidators.ts +19 -17
  28. package/src/runtime/backend/contracts/index.ts +1 -0
  29. package/src/runtime/backend/node/node-backend-adapter-support.ts +13 -2
  30. package/src/runtime/backend/node/node-socket-backend-adapter.ts +7 -0
  31. package/src/runtime/channels/channel-clients.ts +2 -0
  32. package/src/runtime/channels/channel-socket-registry.ts +22 -2
  33. package/src/runtime/channels/fanout-service-wire.ts +5 -8
  34. package/src/runtime/locations/in-memory-authority-store.ts +9 -1
  35. package/src/runtime/locations/location-store-repository.ts +73 -3
  36. package/src/runtime/streams/index.ts +18 -1
@@ -138,7 +138,9 @@ export interface ZLinkFanoutChannelBuilder {
138
138
  setAdvertiseHost(advertiseHost: string): this;
139
139
  routingId(routingId: string): this;
140
140
  setRoutingIdPrefix(prefix: string): this;
141
+ setNoDrop(noDrop?: boolean): this;
141
142
  enableSubscriber(): this;
143
+ subscribe(topic: string): this;
142
144
  connect(endpoint: string): this;
143
145
  subscriberConnections(): ZLinkEndpointConnections;
144
146
  }
@@ -71,8 +71,8 @@ export declare function parseEndpointHostPort(endpoint: string, expectedScheme:
71
71
  * endpoint, or (when `restrictToScheme` is given) when its scheme doesn't
72
72
  * match after lowercasing -- callers translate that into their own
73
73
  * capability-specific `ZLinkConfigurationException` message. Returns
74
- * `boundEndpoint` normalized (unrestricted) when `advertiseHost` is
75
- * undefined -- the common case, and the actual bound endpoint that ends up
76
- * on the wire and in descriptors compared elsewhere.
74
+ * `boundEndpoint` normalized when `advertiseHost` is undefined, except that
75
+ * wildcard TCP hosts use the same-family loopback address. This keeps the
76
+ * bind-only default connectable without advertising a wildcard address.
77
77
  */
78
78
  export declare function buildAdvertisedEndpoint(boundEndpoint: string, advertiseHost: string | undefined, restrictToScheme?: string): string | undefined;
@@ -173,7 +173,7 @@ function parseEndpointHostPort(endpoint, expectedScheme) {
173
173
  port: /^\d+$/.test(port) ? Number(port) : 0
174
174
  };
175
175
  }
176
- const ADVERTISE_SOURCE_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/(?:\[[^\]]+\]|[^:]+):(\d+)$/;
176
+ const ADVERTISE_SOURCE_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/(\[[^\]]+\]|[^:]+):(\d+)$/;
177
177
  /**
178
178
  * Builds a normalized `scheme://advertiseHost:port` endpoint from a bound
179
179
  * endpoint, replacing only the host. Consolidates what used to be three
@@ -186,13 +186,27 @@ const ADVERTISE_SOURCE_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/(?:\[[^\]]+\]|[
186
186
  * endpoint, or (when `restrictToScheme` is given) when its scheme doesn't
187
187
  * match after lowercasing -- callers translate that into their own
188
188
  * capability-specific `ZLinkConfigurationException` message. Returns
189
- * `boundEndpoint` normalized (unrestricted) when `advertiseHost` is
190
- * undefined -- the common case, and the actual bound endpoint that ends up
191
- * on the wire and in descriptors compared elsewhere.
189
+ * `boundEndpoint` normalized when `advertiseHost` is undefined, except that
190
+ * wildcard TCP hosts use the same-family loopback address. This keeps the
191
+ * bind-only default connectable without advertising a wildcard address.
192
192
  */
193
193
  function buildAdvertisedEndpoint(boundEndpoint, advertiseHost, restrictToScheme) {
194
- if (advertiseHost === undefined)
195
- return normalizeEndpoint(boundEndpoint);
194
+ if (advertiseHost === undefined) {
195
+ const normalized = normalizeEndpoint(boundEndpoint);
196
+ const match = ADVERTISE_SOURCE_PATTERN.exec(normalized);
197
+ if (match === null)
198
+ return normalized;
199
+ const boundHost = unbracketHost(match[2]);
200
+ const defaultHost = boundHost === '0.0.0.0'
201
+ ? '127.0.0.1'
202
+ : boundHost === '::'
203
+ ? '::1'
204
+ : undefined;
205
+ if (defaultHost === undefined)
206
+ return normalized;
207
+ const host = defaultHost.includes(':') ? `[${defaultHost}]` : defaultHost;
208
+ return normalizeEndpoint(`${match[1].toLowerCase()}://${host}:${match[3]}`);
209
+ }
196
210
  const match = ADVERTISE_SOURCE_PATTERN.exec(boundEndpoint);
197
211
  if (match === null)
198
212
  return undefined;
@@ -202,7 +216,10 @@ function buildAdvertisedEndpoint(boundEndpoint, advertiseHost, restrictToScheme)
202
216
  const host = advertiseHost.includes(':') && !advertiseHost.startsWith('[')
203
217
  ? `[${advertiseHost}]`
204
218
  : advertiseHost;
205
- return normalizeEndpoint(`${scheme}://${host}:${match[2]}`);
219
+ return normalizeEndpoint(`${scheme}://${host}:${match[3]}`);
220
+ }
221
+ function unbracketHost(host) {
222
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
206
223
  }
207
224
  function normalizeSuffix(suffix) {
208
225
  if (suffix.length === 0) {
@@ -0,0 +1,2 @@
1
+ export declare const FANOUT_LIVENESS_TOPIC = "\u0001ZLF1";
2
+ export declare function requirePublicFanoutTopic(topic: string): void;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FANOUT_LIVENESS_TOPIC = void 0;
4
+ exports.requirePublicFanoutTopic = requirePublicFanoutTopic;
5
+ const ConfigurationException_1 = require("./ConfigurationException");
6
+ exports.FANOUT_LIVENESS_TOPIC = '\x01ZLF1';
7
+ function requirePublicFanoutTopic(topic) {
8
+ if (typeof topic !== 'string') {
9
+ throw new ConfigurationException_1.ZLinkConfigurationException('Fanout topic must be a string.');
10
+ }
11
+ if (topic.startsWith(exports.FANOUT_LIVENESS_TOPIC)) {
12
+ throw new ConfigurationException_1.ZLinkConfigurationException('Fanout topic is reserved for framework liveness.');
13
+ }
14
+ }
@@ -14,6 +14,7 @@ const EndpointNotation_1 = require("./EndpointNotation");
14
14
  const InternalDefaults_1 = require("./InternalDefaults");
15
15
  const RegistrationBuilderPolicy_1 = require("./RegistrationBuilderPolicy");
16
16
  const DiagnosticsValidation_1 = require("./DiagnosticsValidation");
17
+ const FanoutTopic_1 = require("./FanoutTopic");
17
18
  function createFrameworkOptions(configure) {
18
19
  const builder = new ZLinkFrameworkOptionsBuilder();
19
20
  configure(builder);
@@ -362,6 +363,10 @@ class DefaultFanoutChannelBuilder {
362
363
  this.channel.routingIdPrefix = (0, RegistrationBuilderPolicy_1.validateRoutingIdPrefix)(prefix);
363
364
  return this;
364
365
  }
366
+ setNoDrop(noDrop = true) {
367
+ this.channel.noDrop = noDrop;
368
+ return this;
369
+ }
365
370
  enableSubscriber(endpoint) {
366
371
  this.selectSubscriberMode(endpoint === undefined ? 'automatic' : 'manual');
367
372
  this.channel.subscriber ??= { manualConnections: [] };
@@ -373,6 +378,14 @@ class DefaultFanoutChannelBuilder {
373
378
  }
374
379
  return this;
375
380
  }
381
+ subscribe(topic) {
382
+ (0, FanoutTopic_1.requirePublicFanoutTopic)(topic);
383
+ this.channel.subscriptions ??= [];
384
+ if (!this.channel.subscriptions.includes(topic)) {
385
+ this.channel.subscriptions.push(topic);
386
+ }
387
+ return this;
388
+ }
376
389
  connect(endpoint) {
377
390
  requireRegistrationName(endpoint, `Fanout channel '${this.name}' subscriber endpoint`);
378
391
  this.selectSubscriberMode('manual');
@@ -138,11 +138,13 @@ export interface ZLinkFrameworkRegistrationOptions {
138
138
  export interface ZLinkChannelOptions {
139
139
  readonly routingId?: string;
140
140
  readonly routingIdPrefix?: string;
141
+ readonly noDrop?: boolean;
141
142
  readonly requestTimeoutMs?: number;
142
143
  readonly client?: ZLinkClientCapabilityOptions;
143
144
  readonly publisher?: ZLinkPublisherCapabilityOptions;
144
145
  readonly routeMesh?: ZLinkRouteMeshChannelOptions;
145
146
  readonly publishHandlers?: readonly ZLinkChannelPublishHandlerRegistration[];
147
+ readonly subscriptions?: readonly string[];
146
148
  readonly requestHandlers?: readonly ZLinkChannelRequestHandlerRegistration[];
147
149
  readonly sendHandlers?: readonly ZLinkChannelSendHandlerRegistration[];
148
150
  readonly server?: {
@@ -7,8 +7,9 @@ const RouteChannelInternalState_1 = require("./RouteChannelInternalState");
7
7
  const TimerRegistrationValidator_1 = require("./TimerRegistrationValidator");
8
8
  const Locations_1 = require("../Locations");
9
9
  const SendTimeoutValidation_1 = require("./SendTimeoutValidation");
10
+ const FanoutTopic_1 = require("./FanoutTopic");
10
11
  function validateFrameworkRegistration(registration, _options = {}) {
11
- validateListenerNetworkIdentity('process network', undefined, registration.network.bindHost, registration.network.advertiseHost);
12
+ validateListenerNetworkIdentity('process network', registration.network.bindHost, registration.network.advertiseHost);
12
13
  const actorCapableSpotNodes = [...registration.spotNodes.values()]
13
14
  .filter((spotNode) => toActorFactoryCount(spotNode.actorFactories) > 0);
14
15
  if (actorCapableSpotNodes.length > 1) {
@@ -143,9 +144,21 @@ function requireNonNegativeSafeInteger(label, value) {
143
144
  }
144
145
  function validateChannelCapabilities(channels, peerLocationConfigured) {
145
146
  for (const [channelName, channel] of Object.entries(channels ?? {})) {
147
+ for (const topic of channel.subscriptions ?? []) {
148
+ (0, FanoutTopic_1.requirePublicFanoutTopic)(topic);
149
+ }
150
+ if ((channel.subscriptions ?? []).length > 0 && channel.subscriber === undefined) {
151
+ throw new ConfigurationException_1.ZLinkConfigurationException(`Channel '${channelName}' fanout subscriptions require a subscriber capability.`);
152
+ }
153
+ if (channel.noDrop !== undefined && typeof channel.noDrop !== 'boolean') {
154
+ throw new ConfigurationException_1.ZLinkConfigurationException(`Channel '${channelName}' NoDrop must be a boolean.`);
155
+ }
156
+ if (channel.noDrop !== undefined && channel.publisher === undefined) {
157
+ throw new ConfigurationException_1.ZLinkConfigurationException(`Channel '${channelName}' NoDrop requires a publisher role.`);
158
+ }
146
159
  if (channel.server !== undefined) {
147
160
  requireEndpoint(`channel '${channelName}' server`, channel.server.bind);
148
- validateListenerNetworkIdentity(`channel '${channelName}' server`, channel.server.bind, channel.server.bindHost, channel.server.advertiseHost);
161
+ validateListenerNetworkIdentity(`channel '${channelName}' server`, channel.server.bindHost, channel.server.advertiseHost);
149
162
  if (channel.server.routingId !== undefined) {
150
163
  requireName(`channel '${channelName}' server routingId`, channel.server.routingId);
151
164
  }
@@ -154,7 +167,7 @@ function validateChannelCapabilities(channels, peerLocationConfigured) {
154
167
  }
155
168
  if (channel.publisher !== undefined) {
156
169
  requireEndpoint(`channel '${channelName}' publisher`, channel.publisher.bind);
157
- validateListenerNetworkIdentity(`channel '${channelName}' publisher`, channel.publisher.bind, channel.publisher.bindHost, channel.publisher.advertiseHost);
170
+ validateListenerNetworkIdentity(`channel '${channelName}' publisher`, channel.publisher.bindHost, channel.publisher.advertiseHost);
158
171
  validateFanoutPublisherIdentity(channelName, channel.routingId, channel.routingIdPrefix, peerLocationConfigured);
159
172
  }
160
173
  if (channel.client !== undefined) {
@@ -199,7 +212,7 @@ function validateSpotNodes(registration) {
199
212
  validateSpotNodeCapability(`SpotNode '${spotNodeName}' router`, spotNode.router);
200
213
  validateMeshChannels(spotNodeName, spotNode.meshChannels);
201
214
  if (spotNode.router !== undefined) {
202
- validateListenerNetworkIdentity(`SpotNode '${spotNodeName}' router`, spotNode.router.bind, spotNode.router.bindHost, spotNode.router.advertiseHost);
215
+ validateListenerNetworkIdentity(`SpotNode '${spotNodeName}' router`, spotNode.router.bindHost, spotNode.router.advertiseHost);
203
216
  }
204
217
  validateSpotNodeCapability(`SpotNode '${spotNodeName}' pubSub`, spotNode.pubSub);
205
218
  requireNonNegativeSafeInteger(`SpotNode '${spotNodeName}' instanceSpotIdleTimeoutMs`, spotNode.instanceSpotIdleTimeoutMs);
@@ -264,8 +277,7 @@ function validateSpotNodeCapability(capabilityName, capability) {
264
277
  throw new ConfigurationException_1.ZLinkConfigurationException(`${capabilityName} routingId must not be empty or padded.`);
265
278
  }
266
279
  }
267
- function validateListenerNetworkIdentity(capabilityName, bindEndpoint, configuredBindHost, advertiseHost) {
268
- const bindHost = configuredBindHost ?? tcpEndpointHost(bindEndpoint);
280
+ function validateListenerNetworkIdentity(capabilityName, configuredBindHost, advertiseHost) {
269
281
  if (configuredBindHost !== undefined) {
270
282
  requireName(`${capabilityName} bind host`, configuredBindHost);
271
283
  }
@@ -275,13 +287,6 @@ function validateListenerNetworkIdentity(capabilityName, bindEndpoint, configure
275
287
  throw new ConfigurationException_1.ZLinkConfigurationException(`${capabilityName} advertise host must identify a connectable host, not a wildcard address.`);
276
288
  }
277
289
  }
278
- if (bindHost !== undefined && isWildcardHost(bindHost) && advertiseHost === undefined) {
279
- throw new ConfigurationException_1.ZLinkConfigurationException(`${capabilityName} must define an advertise host when its bind host is a wildcard address.`);
280
- }
281
- }
282
- function tcpEndpointHost(endpoint) {
283
- const match = /^tcp:\/\/(\[[^\]]+\]|[^:]+):\d+$/.exec(endpoint ?? '');
284
- return match?.[1];
285
290
  }
286
291
  function isWildcardHost(host) {
287
292
  const normalized = host.startsWith('[') && host.endsWith(']')
@@ -359,7 +364,7 @@ function validateStreamNodes(registration) {
359
364
  for (const [streamNodeName, streamNode] of registration.streamNodes.entries()) {
360
365
  requireNonNegativeInteger(`STREAM node '${streamNodeName}' maxMessageSize`, streamNode.maxMessageSize);
361
366
  requireEndpoint(`STREAM node '${streamNodeName}'`, streamNode.bind);
362
- validateListenerNetworkIdentity(`STREAM node '${streamNodeName}'`, streamNode.bind, streamNode.bindHost, streamNode.advertiseHost);
367
+ validateListenerNetworkIdentity(`STREAM node '${streamNodeName}'`, streamNode.bindHost, streamNode.advertiseHost);
363
368
  if (streamNode.tlsServer !== undefined) {
364
369
  requireFilePath(`STREAM node '${streamNodeName}' TLS certificate`, streamNode.tlsServer.certificatePath);
365
370
  requireFilePath(`STREAM node '${streamNodeName}' TLS key`, streamNode.tlsServer.keyPath);
@@ -351,6 +351,7 @@ export interface ZLinkBackendRouterSocket extends ZLinkBackendConnectableSocket
351
351
  }
352
352
  export interface ZLinkBackendPublisherSocket extends ZLinkBackendSocket {
353
353
  sendHighWaterMark: number;
354
+ noDrop: boolean;
354
355
  publish(topic: string, message: Message | readonly Message[]): void;
355
356
  }
356
357
  export interface ZLinkBackendSubscriberSocket extends ZLinkBackendConnectableSocket {
@@ -17,6 +17,7 @@ exports.translateBindingResultError = translateBindingResultError;
17
17
  exports.toNativeActorRef = toNativeActorRef;
18
18
  const node_backend_adapter_1 = require("../node-backend-adapter");
19
19
  const runtime_values_1 = require("../runtime-values");
20
+ const submission_result_1 = require("../../messaging/submission-result");
20
21
  exports.zlink = (0, node_backend_adapter_1.loadBinding)();
21
22
  function isBindingNotFound(error) {
22
23
  return error instanceof exports.zlink.ConfigError && error.result === exports.zlink.ConfigResult.NotFound;
@@ -47,7 +48,14 @@ function submitBindingPublish(operation, payload) {
47
48
  current.submit();
48
49
  }
49
50
  catch (error) {
50
- throw translateBindingResultError(error);
51
+ const translated = translateBindingResultError(error);
52
+ if (translated instanceof runtime_values_1.ZLinkBackendResultError
53
+ && translated.operation === 'submit'
54
+ && (translated.result === runtime_values_1.SubmitResult.Backpressured
55
+ || translated.result === runtime_values_1.SubmitResult.NotAdmitted)) {
56
+ (0, submission_result_1.requireOneWayCompletion)({ status: submission_result_1.ZLinkSubmitStatus.Backpressured }, 'Classic fanout publish');
57
+ }
58
+ throw translated;
51
59
  }
52
60
  }
53
61
  function submitBindingReply(operation, payload) {
@@ -80,6 +80,12 @@ function wrapSocket(nativeInstance, pollCompletion) {
80
80
  set sendHighWaterMark(value) {
81
81
  requireSocketOptions(socket).sendHwm = value;
82
82
  },
83
+ get noDrop() {
84
+ return socket.options?.noDrop ?? false;
85
+ },
86
+ set noDrop(value) {
87
+ requireSocketOptions(socket).noDrop = value;
88
+ },
83
89
  get receiveHighWaterMark() {
84
90
  return Number(socket.options?.recvHwm ?? 0n);
85
91
  },
@@ -6,6 +6,7 @@ const framework_errors_internal_1 = require("../framework-errors-internal");
6
6
  const submission_result_1 = require("../messaging/submission-result");
7
7
  const abort_1 = require("../abort");
8
8
  const execution_1 = require("../execution");
9
+ const fanout_service_wire_1 = require("./fanout-service-wire");
9
10
  const spot_outbound_1 = require("../spots/spot-outbound");
10
11
  const packet_name_1 = require("../messaging/packet-name");
11
12
  class DefaultZLinkChannelClient {
@@ -59,6 +60,7 @@ class DefaultZLinkFanoutClient {
59
60
  const topic = hasExplicitTopic
60
61
  ? topicOrEvent
61
62
  : (0, packet_name_1.resolveFrameworkPacketName)(event, undefined, 'Fanout');
63
+ (0, fanout_service_wire_1.requirePublicFanoutTopic)(topic);
62
64
  const packetName = (0, packet_name_1.resolveFrameworkPacketName)(event, undefined, 'Fanout');
63
65
  return new DefaultZLinkFanoutPublishCall(() => this.requirePublisherChannel(channelName), async (signal) => ({
64
66
  status: (await this.requireTransport().publish(channelName, topic, packetName, event, signal)).status
@@ -730,8 +730,7 @@ class ZLinkChannelSocketRegistry {
730
730
  }
731
731
  const subscriber = this.adapter.createSubscriberSocket(this.context);
732
732
  subscriber.setChannelName(channelName);
733
- subscriber.setSubscription('');
734
- subscriber.setSubscription(fanout_service_wire_1.FANOUT_LIVENESS_TOPIC);
733
+ setFanoutSubscriptions(subscriber, channel.subscriptions);
735
734
  const monitor = this.monitoringAdapter.openSocketMonitor(subscriber);
736
735
  const connection = {
737
736
  channelName,
@@ -913,6 +912,9 @@ class ZLinkChannelSocketRegistry {
913
912
  try {
914
913
  publisher.publish(fanout_service_wire_1.FANOUT_LIVENESS_TOPIC, payload);
915
914
  }
915
+ catch (error) {
916
+ this.oneWayFailureSink?.(error);
917
+ }
916
918
  finally {
917
919
  payload.close();
918
920
  }
@@ -1216,6 +1218,7 @@ class ZLinkChannelSocketRegistry {
1216
1218
  }
1217
1219
  const publisher = this.adapter.createPublisherSocket(this.context);
1218
1220
  publisher.setChannelName(channelName);
1221
+ applyFanoutPublisherSocketOptions(publisher, channel);
1219
1222
  publisher.bind(channel.publisher.bind);
1220
1223
  this.publishers.set(channelName, publisher);
1221
1224
  this.fanoutPublisherNextBeacon.set(channelName, performance.now() + CLIENT_SERVER_PROBE_INTERVAL_MS);
@@ -1386,6 +1389,14 @@ function closeMessages(parts) {
1386
1389
  for (const part of parts)
1387
1390
  part.close();
1388
1391
  }
1392
+ function setFanoutSubscriptions(subscriber, applicationTopics) {
1393
+ const topics = new Set(applicationTopics);
1394
+ if (topics.size === 0)
1395
+ topics.add('');
1396
+ topics.add(fanout_service_wire_1.FANOUT_LIVENESS_TOPIC);
1397
+ for (const topic of topics)
1398
+ subscriber.setSubscription(topic);
1399
+ }
1389
1400
  function deriveRoutingId(baseRoutingId, suffix) {
1390
1401
  const derived = `${baseRoutingId}\0${suffix}`;
1391
1402
  if (Buffer.byteLength(derived, 'utf8') > 255) {
@@ -1393,6 +1404,9 @@ function deriveRoutingId(baseRoutingId, suffix) {
1393
1404
  }
1394
1405
  return derived;
1395
1406
  }
1407
+ function applyFanoutPublisherSocketOptions(publisher, channel) {
1408
+ publisher.noDrop = channel.noDrop ?? false;
1409
+ }
1396
1410
  function fanoutDiscoveryConnectionId(connectionId) {
1397
1411
  return `fanout:${connectionId.replaceAll('\0', '/')}`;
1398
1412
  }
@@ -1,5 +1,6 @@
1
+ import { FANOUT_LIVENESS_TOPIC, requirePublicFanoutTopic } from '../../contracts/Configuration/FanoutTopic';
1
2
  import type { ZLinkChannelEnvelopeHeader } from './channel-envelope';
2
- export declare const FANOUT_LIVENESS_TOPIC = "\u0001ZLF1";
3
+ export { FANOUT_LIVENESS_TOPIC, requirePublicFanoutTopic };
3
4
  export declare const FANOUT_LIVENESS_PAYLOAD: Uint8Array<ArrayBuffer>;
4
5
  export type ZLinkFanoutInboundKind = 'application' | 'beacon' | 'protocolError';
5
6
  export interface ZLinkFanoutInboundClassification {
@@ -9,4 +10,3 @@ export interface ZLinkFanoutInboundClassification {
9
10
  export declare function inspectFanoutInbound(topic: string, parts: readonly {
10
11
  data(): Uint8Array;
11
12
  }[]): ZLinkFanoutInboundClassification;
12
- export declare function requirePublicFanoutTopic(topic: string): void;
@@ -1,14 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FANOUT_LIVENESS_PAYLOAD = exports.FANOUT_LIVENESS_TOPIC = void 0;
3
+ exports.FANOUT_LIVENESS_PAYLOAD = exports.requirePublicFanoutTopic = exports.FANOUT_LIVENESS_TOPIC = void 0;
4
4
  exports.inspectFanoutInbound = inspectFanoutInbound;
5
- exports.requirePublicFanoutTopic = requirePublicFanoutTopic;
6
- const configuration_1 = require("../configuration");
5
+ const FanoutTopic_1 = require("../../contracts/Configuration/FanoutTopic");
6
+ Object.defineProperty(exports, "FANOUT_LIVENESS_TOPIC", { enumerable: true, get: function () { return FanoutTopic_1.FANOUT_LIVENESS_TOPIC; } });
7
+ Object.defineProperty(exports, "requirePublicFanoutTopic", { enumerable: true, get: function () { return FanoutTopic_1.requirePublicFanoutTopic; } });
7
8
  const channel_envelope_inspection_1 = require("./channel-envelope-inspection");
8
- exports.FANOUT_LIVENESS_TOPIC = '\x01ZLF1';
9
9
  exports.FANOUT_LIVENESS_PAYLOAD = Uint8Array.from([0x5a, 0x46, 0x01, 0x01]);
10
10
  function inspectFanoutInbound(topic, parts) {
11
- if (topic !== exports.FANOUT_LIVENESS_TOPIC) {
11
+ if (topic !== FanoutTopic_1.FANOUT_LIVENESS_TOPIC) {
12
12
  const header = (0, channel_envelope_inspection_1.tryDecodeChannelHeader)(parts);
13
13
  return header === undefined
14
14
  ? { kind: 'protocolError' }
@@ -22,8 +22,3 @@ function inspectFanoutInbound(topic, parts) {
22
22
  ? { kind: 'beacon' }
23
23
  : { kind: 'protocolError' };
24
24
  }
25
- function requirePublicFanoutTopic(topic) {
26
- if (topic === exports.FANOUT_LIVENESS_TOPIC) {
27
- throw new configuration_1.ZLinkConfigurationException('Fanout topic is reserved for framework liveness.');
28
- }
29
- }
@@ -174,7 +174,15 @@ class ZLinkInMemoryAuthorityStore {
174
174
  if (current.snapshot.allocation.state === 'active') {
175
175
  return { kind: 'alreadyExists', current: this.snapshot(current.snapshot) };
176
176
  }
177
- return { kind: 'conflict', current: this.read(key) };
177
+ // An unfinished creation whose owner lease ended is cancellable.
178
+ // Reclaim it so the key does not stay blocked forever.
179
+ if (this.isOwnerLive(current.snapshot) || current.creation === undefined) {
180
+ return { kind: 'conflict', current: this.read(key) };
181
+ }
182
+ this.adjustCapacity(this.pendingCapacity, current.snapshot.allocation, -1);
183
+ this.rows.delete(key);
184
+ this.creationTerminals.set(current.creation.reservationId, 'aborted');
185
+ this.scanRevision++;
178
186
  }
179
187
  const target = creationTarget(request);
180
188
  if (!this.validation.isTargetLive(target.descriptor, target.lifecycleGeneration, target.owner)) {
@@ -758,9 +758,56 @@ class ZLinkLocationStoreRepository extends in_memory_location_store_1.ZLinkInMem
758
758
  || snapshot.allocation.stableType !== request.intent.stableType) {
759
759
  return { kind: 'typeMismatch', current: snapshot };
760
760
  }
761
- return snapshot.allocation.state === 'active'
762
- ? { kind: 'alreadyExists', current: snapshot }
763
- : { kind: 'conflict', current: snapshot };
761
+ if (snapshot.allocation.state === 'active') {
762
+ return { kind: 'alreadyExists', current: snapshot };
763
+ }
764
+ if (record.aggregate !== undefined
765
+ || isCanonicalAuthorityPayload(record.snapshot.payload)) {
766
+ // A relocation record carries its own recovery protocol. Reserve
767
+ // may reclaim only an unfinished plain creation.
768
+ return { kind: 'conflict', current: snapshot };
769
+ }
770
+ // The row is Reserved by another owner. An unfinished creation
771
+ // whose owner lease ended is cancellable, so reclaim it and retry
772
+ // instead of blocking the key forever.
773
+ const staleOwnerKey = ownerKey(snapshot.ownerId);
774
+ const staleCapacityKey = capacityKey(snapshot.allocation.descriptor.meshName, String(snapshot.allocation.descriptor.rid));
775
+ const [staleOwnerRead, staleCapacityRead] = await Promise.all([
776
+ this.provider.read(staleOwnerKey, signal),
777
+ this.provider.read(staleCapacityKey, signal)
778
+ ]);
779
+ if (staleOwnerRead.kind === 'found'
780
+ && staleOwnerRead.value.expiresAt === undefined) {
781
+ // A lease is always written with a positive TTL, so a missing
782
+ // expiry is a corrupt record. Reclaiming deletes authority state,
783
+ // so refuse rather than read the absence as expiry.
784
+ throw new Error('Location Store owner lease record is invalid.');
785
+ }
786
+ if (sameLiveOwner(staleOwnerRead, record.snapshot)) {
787
+ return { kind: 'conflict', current: snapshot };
788
+ }
789
+ const staleCapacity = staleCapacityRead.kind === 'missing'
790
+ ? emptyCapacityRecord()
791
+ : decodeJson(staleCapacityRead.value.bytes);
792
+ await this.provider.write({
793
+ conditions: [
794
+ { kind: 'version', key: rowKey, expected: current.value.version },
795
+ versionCondition(staleOwnerKey, staleOwnerRead),
796
+ conditionFor(staleCapacityKey, staleCapacityRead)
797
+ ],
798
+ mutations: [
799
+ { kind: 'delete', key: rowKey },
800
+ {
801
+ kind: 'put',
802
+ key: staleCapacityKey,
803
+ bytes: encodeJson({
804
+ active: staleCapacity.active,
805
+ pending: subtractCapacity(staleCapacity.pending, record.snapshot.allocation.capacity)
806
+ })
807
+ }
808
+ ]
809
+ }, signal);
810
+ continue;
764
811
  }
765
812
  const descriptor = liveTargetDescriptor(descriptorRead, leaseRead, request.target);
766
813
  if (descriptor === undefined) {
@@ -950,6 +997,12 @@ class ZLinkLocationStoreRepository extends in_memory_location_store_1.ZLinkInMem
950
997
  this.provider.read(leaseKey, signal),
951
998
  this.provider.read(capacityRowKey, signal)
952
999
  ]);
1000
+ if (leaseRead.kind === 'found' && leaseRead.value.expiresAt === undefined) {
1001
+ // A lease is always written with a positive TTL, so a missing expiry
1002
+ // is a corrupt record. Abort deletes authority state, so refuse
1003
+ // rather than read the absence as expiry.
1004
+ throw new Error('Location Store owner lease record is invalid.');
1005
+ }
953
1006
  if (!sameLiveOwner(leaseRead, record.snapshot))
954
1007
  return { kind: 'stale' };
955
1008
  const capacity = capacityRead.kind === 'missing'
@@ -2896,6 +2949,11 @@ function sameCreationTarget(snapshot, target) {
2896
2949
  && snapshot.ownerId === target.owner.ownerId
2897
2950
  && snapshot.ownerLeaseGeneration === target.owner.leaseGeneration;
2898
2951
  }
2952
+ const CANONICAL_AUTHORITY_MAGIC = Buffer.from('ZLAU');
2953
+ function isCanonicalAuthorityPayload(payload) {
2954
+ return payload.byteLength >= CANONICAL_AUTHORITY_MAGIC.byteLength
2955
+ && Buffer.from(payload.buffer, payload.byteOffset, CANONICAL_AUTHORITY_MAGIC.byteLength).equals(CANONICAL_AUTHORITY_MAGIC);
2956
+ }
2899
2957
  function sameLiveOwner(lease, snapshot) {
2900
2958
  if (lease.kind === 'missing')
2901
2959
  return false;
@@ -3,7 +3,7 @@ import type { ZLinkProviderResolver } from '../../contracts/Common/ZLinkProvider
3
3
  import type { ZLinkSubmitResult } from '../messaging/submission-result';
4
4
  import { ZLinkMessage } from '../../contracts';
5
5
  import type { Message } from '../../contracts/Common/Message';
6
- import type { ZLinkFrameworkRegistration } from '../configuration';
6
+ import { type ZLinkFrameworkRegistration } from '../configuration';
7
7
  import { ZLinkDispatchErrorReporter } from '../channels';
8
8
  import type { ZLinkBackendAdapterFactory, ZLinkBackendActorSessionNode, ZLinkBackendContext, ZLinkBackendMeshNode } from '../backend/contracts';
9
9
  import type { ZLinkMeshCompletionTable } from '../backend/mesh-completion-table';
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ZLinkStreamBindingRuntime = exports.ZLinkStreamSessionNodeRuntime = exports.ZLinkStreamSessionRuntime = exports.ZLinkStreamRuntimeManager = exports.DefaultZLinkSessionContext = exports.DefaultZLinkSessionActor = exports.DefaultZLinkBoundSessionFactory = exports.DefaultZLinkBoundSession = exports.ZLinkManagedStream = exports.zlinkStreamLz4CompressionCodec = exports.ZLinkActorSessionBindingRegistry = exports.ZLinkActorSessionLifecycleCoordinator = exports.ZLinkPendingSessionRequest = void 0;
4
+ const configuration_1 = require("../configuration");
4
5
  const service_session_binding_ingress_port_1 = require("../foundation/service-session-binding-ingress-port");
5
6
  const protocol_1 = require("./protocol");
6
7
  const actor_session_binding_registry_1 = require("./actor-session-binding-registry");
@@ -64,6 +65,11 @@ class ZLinkStreamRuntimeManager {
64
65
  socket.setTlsServer(tlsServer.certificatePath, tlsServer.keyPath, tlsServer.requireClientCertificate ?? false);
65
66
  }
66
67
  socket.bind(streamNode.bind);
68
+ const boundEndpoint = socket.lastEndpoint ?? streamNode.bind;
69
+ const advertisedEndpoint = (0, configuration_1.buildAdvertisedEndpoint)(boundEndpoint, streamNode.advertiseHost, 'tcp');
70
+ if (advertisedEndpoint === undefined) {
71
+ throw new configuration_1.ZLinkConfigurationException(`STREAM node '${nodeName}' advertised host requires a TCP endpoint, received '${boundEndpoint}'.`);
72
+ }
67
73
  const readablePoller = streamAdapter.createReadablePoller(socket);
68
74
  const nativeSessionRoutes = new Map();
69
75
  if (actorDispatchEnabled) {
@@ -120,6 +126,7 @@ class ZLinkStreamRuntimeManager {
120
126
  runtime.start();
121
127
  this.nodes.set(nodeName, {
122
128
  meshName: applicationMeshName,
129
+ advertisedEndpoint,
123
130
  runtime,
124
131
  socket,
125
132
  monitor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zlink-systems/framework",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  "dependencies": {
20
20
  "@opentelemetry/api": "^1.9.1",
21
21
  "@opentelemetry/api-logs": "^0.221.0",
22
- "@zlink-systems/stream-wire": "0.13.0",
23
- "@zlink-systems/zlink": "1.1.0"
22
+ "@zlink-systems/stream-wire": "0.15.0",
23
+ "@zlink-systems/zlink": "1.2.0"
24
24
  }
25
25
  }
@@ -184,7 +184,9 @@ export interface ZLinkFanoutChannelBuilder {
184
184
  setAdvertiseHost(advertiseHost: string): this;
185
185
  routingId(routingId: string): this;
186
186
  setRoutingIdPrefix(prefix: string): this;
187
+ setNoDrop(noDrop?: boolean): this;
187
188
  enableSubscriber(): this;
189
+ subscribe(topic: string): this;
188
190
  connect(endpoint: string): this;
189
191
  subscriberConnections(): ZLinkEndpointConnections;
190
192
  }
@@ -184,7 +184,7 @@ export function parseEndpointHostPort(
184
184
  };
185
185
  }
186
186
 
187
- const ADVERTISE_SOURCE_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/(?:\[[^\]]+\]|[^:]+):(\d+)$/;
187
+ const ADVERTISE_SOURCE_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/(\[[^\]]+\]|[^:]+):(\d+)$/;
188
188
 
189
189
  /**
190
190
  * Builds a normalized `scheme://advertiseHost:port` endpoint from a bound
@@ -198,16 +198,29 @@ const ADVERTISE_SOURCE_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/(?:\[[^\]]+\]|[
198
198
  * endpoint, or (when `restrictToScheme` is given) when its scheme doesn't
199
199
  * match after lowercasing -- callers translate that into their own
200
200
  * capability-specific `ZLinkConfigurationException` message. Returns
201
- * `boundEndpoint` normalized (unrestricted) when `advertiseHost` is
202
- * undefined -- the common case, and the actual bound endpoint that ends up
203
- * on the wire and in descriptors compared elsewhere.
201
+ * `boundEndpoint` normalized when `advertiseHost` is undefined, except that
202
+ * wildcard TCP hosts use the same-family loopback address. This keeps the
203
+ * bind-only default connectable without advertising a wildcard address.
204
204
  */
205
205
  export function buildAdvertisedEndpoint(
206
206
  boundEndpoint: string,
207
207
  advertiseHost: string | undefined,
208
208
  restrictToScheme?: string
209
209
  ): string | undefined {
210
- if (advertiseHost === undefined) return normalizeEndpoint(boundEndpoint);
210
+ if (advertiseHost === undefined) {
211
+ const normalized = normalizeEndpoint(boundEndpoint);
212
+ const match = ADVERTISE_SOURCE_PATTERN.exec(normalized);
213
+ if (match === null) return normalized;
214
+ const boundHost = unbracketHost(match[2]);
215
+ const defaultHost = boundHost === '0.0.0.0'
216
+ ? '127.0.0.1'
217
+ : boundHost === '::'
218
+ ? '::1'
219
+ : undefined;
220
+ if (defaultHost === undefined) return normalized;
221
+ const host = defaultHost.includes(':') ? `[${defaultHost}]` : defaultHost;
222
+ return normalizeEndpoint(`${match[1].toLowerCase()}://${host}:${match[3]}`);
223
+ }
211
224
  const match = ADVERTISE_SOURCE_PATTERN.exec(boundEndpoint);
212
225
  if (match === null) return undefined;
213
226
  const scheme = match[1].toLowerCase();
@@ -215,7 +228,11 @@ export function buildAdvertisedEndpoint(
215
228
  const host = advertiseHost.includes(':') && !advertiseHost.startsWith('[')
216
229
  ? `[${advertiseHost}]`
217
230
  : advertiseHost;
218
- return normalizeEndpoint(`${scheme}://${host}:${match[2]}`);
231
+ return normalizeEndpoint(`${scheme}://${host}:${match[3]}`);
232
+ }
233
+
234
+ function unbracketHost(host: string): string {
235
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
219
236
  }
220
237
 
221
238
  function normalizeSuffix(suffix: string): string {
@@ -0,0 +1,12 @@
1
+ import { ZLinkConfigurationException } from './ConfigurationException';
2
+
3
+ export const FANOUT_LIVENESS_TOPIC = '\x01ZLF1';
4
+
5
+ export function requirePublicFanoutTopic(topic: string): void {
6
+ if (typeof topic !== 'string') {
7
+ throw new ZLinkConfigurationException('Fanout topic must be a string.');
8
+ }
9
+ if (topic.startsWith(FANOUT_LIVENESS_TOPIC)) {
10
+ throw new ZLinkConfigurationException('Fanout topic is reserved for framework liveness.');
11
+ }
12
+ }