@naylence/runtime 0.3.14 → 0.3.16

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 (49) hide show
  1. package/dist/browser/index.cjs +1160 -977
  2. package/dist/browser/index.mjs +1157 -979
  3. package/dist/cjs/browser.js +15 -0
  4. package/dist/cjs/naylence/fame/factory-manifest.js +2 -0
  5. package/dist/cjs/naylence/fame/node/connection-retry-policy-factory.js +22 -0
  6. package/dist/cjs/naylence/fame/node/connection-retry-policy.js +2 -0
  7. package/dist/cjs/naylence/fame/node/default-connection-retry-policy-factory.js +36 -0
  8. package/dist/cjs/naylence/fame/node/default-connection-retry-policy.js +51 -0
  9. package/dist/cjs/naylence/fame/node/factory-commons.js +15 -0
  10. package/dist/cjs/naylence/fame/node/index.js +6 -1
  11. package/dist/cjs/naylence/fame/node/node-config.js +4 -0
  12. package/dist/cjs/naylence/fame/node/node-factory.js +1 -0
  13. package/dist/cjs/naylence/fame/node/node.js +3 -0
  14. package/dist/cjs/naylence/fame/node/upstream-session-manager.js +63 -23
  15. package/dist/cjs/naylence/fame/sentinel/sentinel.js +2 -0
  16. package/dist/cjs/node.js +12 -1
  17. package/dist/cjs/version.js +2 -2
  18. package/dist/esm/browser.js +15 -0
  19. package/dist/esm/naylence/fame/factory-manifest.js +2 -0
  20. package/dist/esm/naylence/fame/node/connection-retry-policy-factory.js +18 -0
  21. package/dist/esm/naylence/fame/node/connection-retry-policy.js +1 -0
  22. package/dist/esm/naylence/fame/node/default-connection-retry-policy-factory.js +32 -0
  23. package/dist/esm/naylence/fame/node/default-connection-retry-policy.js +47 -0
  24. package/dist/esm/naylence/fame/node/factory-commons.js +15 -0
  25. package/dist/esm/naylence/fame/node/index.js +4 -0
  26. package/dist/esm/naylence/fame/node/node-config.js +4 -0
  27. package/dist/esm/naylence/fame/node/node-factory.js +1 -0
  28. package/dist/esm/naylence/fame/node/node.js +3 -0
  29. package/dist/esm/naylence/fame/node/upstream-session-manager.js +63 -23
  30. package/dist/esm/naylence/fame/sentinel/sentinel.js +2 -0
  31. package/dist/esm/node.js +12 -1
  32. package/dist/esm/version.js +2 -2
  33. package/dist/node/index.cjs +615 -442
  34. package/dist/node/index.mjs +611 -443
  35. package/dist/node/node.cjs +1191 -1008
  36. package/dist/node/node.mjs +1188 -1010
  37. package/dist/types/naylence/fame/factory-manifest.d.ts +1 -1
  38. package/dist/types/naylence/fame/node/connection-retry-policy-factory.d.ts +20 -0
  39. package/dist/types/naylence/fame/node/connection-retry-policy.d.ts +44 -0
  40. package/dist/types/naylence/fame/node/default-connection-retry-policy-factory.d.ts +15 -0
  41. package/dist/types/naylence/fame/node/default-connection-retry-policy.d.ts +36 -0
  42. package/dist/types/naylence/fame/node/factory-commons.d.ts +2 -0
  43. package/dist/types/naylence/fame/node/index.d.ts +4 -0
  44. package/dist/types/naylence/fame/node/node-config.d.ts +2 -0
  45. package/dist/types/naylence/fame/node/node.d.ts +3 -0
  46. package/dist/types/naylence/fame/node/upstream-session-manager.d.ts +13 -0
  47. package/dist/types/naylence/fame/sentinel/sentinel.d.ts +1 -0
  48. package/dist/types/version.d.ts +1 -1
  49. package/package.json +1 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Environment variable for overriding max initial attempts.
3
+ */
4
+ export const ENV_VAR_SESSION_MAX_INITIAL_ATTEMPTS = 'FAME_SESSION_MAX_INITIAL_ATTEMPTS';
5
+ /**
6
+ * Default implementation of connection retry policy.
7
+ *
8
+ * Before first successful attach:
9
+ * - Respects maxInitialAttempts configuration
10
+ * - Uses exponential backoff with jitter
11
+ *
12
+ * After first successful attach:
13
+ * - Always retries (unlimited) to maintain connection
14
+ * - Resets backoff if connection was stable for >10 seconds
15
+ */
16
+ export class DefaultConnectionRetryPolicy {
17
+ constructor(options = {}) {
18
+ // Check for environment variable override
19
+ const envValue = typeof process !== 'undefined'
20
+ ? process.env?.[ENV_VAR_SESSION_MAX_INITIAL_ATTEMPTS]
21
+ : undefined;
22
+ if (envValue !== undefined && envValue !== '') {
23
+ const parsed = parseInt(envValue, 10);
24
+ this.maxInitialAttempts = isNaN(parsed) ? (options.maxInitialAttempts ?? 1) : parsed;
25
+ }
26
+ else {
27
+ this.maxInitialAttempts = options.maxInitialAttempts ?? 1;
28
+ }
29
+ }
30
+ shouldRetry(context) {
31
+ // After first successful attach, always retry to maintain connection
32
+ if (context.hadSuccessfulAttach) {
33
+ return true;
34
+ }
35
+ // maxInitialAttempts = 0 means unlimited retries
36
+ if (this.maxInitialAttempts === 0) {
37
+ return true;
38
+ }
39
+ // Fail if we've exceeded the configured max attempts
40
+ return context.attemptNumber < this.maxInitialAttempts;
41
+ }
42
+ calculateRetryDelay(_context, baseDelay) {
43
+ // Add jitter to prevent thundering herd
44
+ const jitter = Math.random() * baseDelay;
45
+ return baseDelay + jitter;
46
+ }
47
+ }
@@ -1,5 +1,6 @@
1
1
  import { createResource } from '@naylence/factory';
2
2
  import { AdmissionClientFactory } from './admission/admission-client-factory.js';
3
+ import { ConnectionRetryPolicyFactory, } from './connection-retry-policy-factory.js';
3
4
  import { DefaultNodeIdentityPolicy } from './default-node-identity-policy.js';
4
5
  import { NodeIdentityPolicyFactory, } from './node-identity-policy-factory.js';
5
6
  import { DefaultNodeAttachClient } from './admission/default-node-attach-client.js';
@@ -124,6 +125,7 @@ export async function makeCommonOptions(config, rawConfig) {
124
125
  const telemetryConfig = pickOption(config.telemetry ?? null, aliasRecord, 'trace_emitter', 'telemetry_config');
125
126
  const securityConfig = pickOption(config.security ?? null, aliasRecord, 'security_manager', 'security_profile');
126
127
  const identityPolicyConfig = pickOption(config.identityPolicy ?? null, aliasRecord, 'identity_policy', 'node_identity_policy');
128
+ const connectionRetryPolicyConfig = pickOption(config.connectionRetryPolicy ?? null, aliasRecord, 'connection_retry_policy', 'retry_policy');
127
129
  const publicUrl = pickString(config.publicUrl ?? null, aliasRecord, 'public_url') ?? null;
128
130
  const directParentUrl = pickString(config.directParentUrl ?? null, aliasRecord, 'direct_parent_url') ?? null;
129
131
  const hasParentFlag = config.hasParent || Boolean(aliasRecord.has_parent ?? false);
@@ -133,6 +135,7 @@ export async function makeCommonOptions(config, rawConfig) {
133
135
  const nodeMetaStore = await storageProvider.getKeyValueStore(NodeMetaRecord, NODE_META_NAMESPACE);
134
136
  const nodeMeta = await nodeMetaStore.get('self');
135
137
  const identityPolicy = await resolveNodeIdentityPolicy(identityPolicyConfig ?? null, expressionOptions);
138
+ const connectionRetryPolicy = await resolveConnectionRetryPolicy(connectionRetryPolicyConfig ?? null, expressionOptions);
136
139
  const admissionClient = await resolveAdmissionClient(admissionConfig ?? null, expressionOptions, identityPolicy ?? undefined);
137
140
  const hasParent = determineHasParent(hasParentFlag, directParentUrl, admissionClient);
138
141
  const replicaStickinessManager = await resolveReplicaStickinessManager(hasParent, requestedLogicals, expressionOptions);
@@ -201,6 +204,7 @@ export async function makeCommonOptions(config, rawConfig) {
201
204
  transportListeners,
202
205
  traceEmitter,
203
206
  identityPolicy: identityPolicy ?? undefined,
207
+ connectionRetryPolicy: connectionRetryPolicy ?? undefined,
204
208
  };
205
209
  }
206
210
  async function resolveNodeIdentityPolicy(config, options) {
@@ -214,6 +218,17 @@ async function resolveNodeIdentityPolicy(config, options) {
214
218
  return null;
215
219
  }
216
220
  }
221
+ async function resolveConnectionRetryPolicy(config, options) {
222
+ try {
223
+ return await ConnectionRetryPolicyFactory.createConnectionRetryPolicy(config ?? undefined, cloneCreateOptions(options));
224
+ }
225
+ catch (error) {
226
+ logger.warning('connection_retry_policy_creation_failed', {
227
+ error: error instanceof Error ? error.message : String(error),
228
+ });
229
+ return null;
230
+ }
231
+ }
217
232
  async function resolveStorageProvider(config, options) {
218
233
  if (config) {
219
234
  try {
@@ -25,3 +25,7 @@ export { DefaultNodeIdentityPolicyFactory, } from './default-node-identity-polic
25
25
  export * from './token-subject-node-identity-policy.js';
26
26
  export { TokenSubjectNodeIdentityPolicyFactory, } from './token-subject-node-identity-policy-factory.js';
27
27
  export { NodeIdentityPolicyProfileFactory, } from './node-identity-policy-profile-factory.js';
28
+ export * from './connection-retry-policy.js';
29
+ export * from './connection-retry-policy-factory.js';
30
+ export * from './default-connection-retry-policy.js';
31
+ export { DefaultConnectionRetryPolicyFactory, } from './default-connection-retry-policy-factory.js';
@@ -23,6 +23,7 @@ const FameNodeConfigSchemaInternal = z
23
23
  telemetry: z.unknown().optional().nullable(),
24
24
  requestedCapabilities: z.array(z.string()).optional(),
25
25
  identityPolicy: z.unknown().optional().nullable(),
26
+ connectionRetryPolicy: z.unknown().optional().nullable(),
26
27
  })
27
28
  .passthrough();
28
29
  export function normalizeFameNodeConfig(input) {
@@ -64,6 +65,9 @@ export function normalizeFameNodeConfig(input) {
64
65
  identityPolicy: parsed.identityPolicy === undefined
65
66
  ? null
66
67
  : parsed.identityPolicy,
68
+ connectionRetryPolicy: parsed.connectionRetryPolicy === undefined
69
+ ? null
70
+ : parsed.connectionRetryPolicy,
67
71
  };
68
72
  if (parsed.requestedCapabilities) {
69
73
  normalized.requestedCapabilities = coerceStringArray(parsed.requestedCapabilities);
@@ -38,6 +38,7 @@ export class NodeFactory extends NodeLikeFactory {
38
38
  nodeMetaStore: components.nodeMetaStore,
39
39
  transportListeners: components.transportListeners,
40
40
  defaultServiceConfigs: serviceConfigs,
41
+ connectionRetryPolicy: components.connectionRetryPolicy,
41
42
  });
42
43
  return node;
43
44
  }
@@ -141,6 +141,8 @@ export class FameNode extends TaskSpawner {
141
141
  this._acceptedLogicals = new Set(acceptedLogicalsOption);
142
142
  const deliveryPolicyOption = resolveOption(options, 'deliveryPolicy', 'delivery_policy');
143
143
  this._deliveryPolicy = deliveryPolicyOption ?? null;
144
+ const connectionRetryPolicyOption = resolveOption(options, 'connectionRetryPolicy', 'connection_retry_policy');
145
+ this._connectionRetryPolicy = connectionRetryPolicyOption ?? null;
144
146
  const admissionClientOption = resolveOption(options, 'admissionClient', 'admission_client');
145
147
  this._admissionClient = admissionClientOption ?? null;
146
148
  const attachClientOption = resolveOption(options, 'attachClient', 'attach_client');
@@ -270,6 +272,7 @@ export class FameNode extends TaskSpawner {
270
272
  onAttach: (info, connector) => this.handleAttach(info, connector),
271
273
  onEpochChange: (epoch) => this.handleEpochChange(epoch),
272
274
  admissionClient: this._admissionClient,
275
+ retryPolicy: this._connectionRetryPolicy,
273
276
  });
274
277
  this._sessionManager = manager;
275
278
  await manager.start();
@@ -82,6 +82,7 @@ function normalizeOptions(options) {
82
82
  const onEpochChangeValue = pickOption(record, 'onEpochChange', 'on_epoch_change');
83
83
  const onEpochChange = typeof onEpochChangeValue === 'function' ? onEpochChangeValue : undefined;
84
84
  const admissionClient = pickOption(record, 'admissionClient', 'admission_client');
85
+ const retryPolicy = pickOption(record, 'retryPolicy', 'retry_policy');
85
86
  return {
86
87
  node,
87
88
  attachClient,
@@ -93,6 +94,7 @@ function normalizeOptions(options) {
93
94
  onAttach: validatedOnAttach,
94
95
  onEpochChange,
95
96
  admissionClient: admissionClient ?? undefined,
97
+ retryPolicy: retryPolicy ?? undefined,
96
98
  };
97
99
  }
98
100
  export class UpstreamSessionManager extends TaskSpawner {
@@ -112,6 +114,7 @@ export class UpstreamSessionManager extends TaskSpawner {
112
114
  this.hadSuccessfulAttach = false;
113
115
  this.lastConnectorState = null;
114
116
  this.connectEpoch = 0;
117
+ this.initialAttempts = 0;
115
118
  this._visibilityHandler = null;
116
119
  const options = normalizeOptions(optionsInput);
117
120
  this.node = options.node;
@@ -125,8 +128,11 @@ export class UpstreamSessionManager extends TaskSpawner {
125
128
  this.admissionClient =
126
129
  options.admissionClient ?? options.node.admissionClient;
127
130
  this.wrappedHandler = this.makeHeartbeatEnabledHandler(options.inboundHandler);
131
+ // Store the connection retry policy (can be null, in which case default behavior applies)
132
+ this.connectionRetryPolicy = options.retryPolicy ?? null;
128
133
  logger.debug('created_upstream_session_manager', {
129
134
  target_system_id: this.targetSystemId,
135
+ has_retry_policy: this.connectionRetryPolicy !== null,
130
136
  });
131
137
  }
132
138
  get systemId() {
@@ -242,11 +248,14 @@ export class UpstreamSessionManager extends TaskSpawner {
242
248
  }
243
249
  async fsmLoop() {
244
250
  let delay = UpstreamSessionManager.BACKOFF_INITIAL;
251
+ this.initialAttempts = 0;
245
252
  while (!this.stopEvent.isSet()) {
246
253
  const startTime = Date.now();
254
+ this.initialAttempts += 1;
247
255
  try {
248
256
  await this.connectCycle();
249
257
  delay = UpstreamSessionManager.BACKOFF_INITIAL;
258
+ this.initialAttempts = 0; // Reset on success
250
259
  }
251
260
  catch (error) {
252
261
  // Reset backoff if the connection was alive for more than 10 seconds
@@ -256,13 +265,17 @@ export class UpstreamSessionManager extends TaskSpawner {
256
265
  if (error instanceof TaskCancelledError) {
257
266
  throw error;
258
267
  }
268
+ // Determine if we should fail-fast or retry
269
+ const shouldFailFast = this.shouldFailFastOnError(error);
259
270
  if (error instanceof FameTransportClose ||
260
271
  error instanceof FameConnectError) {
261
272
  logger.warning('upstream_link_closed', {
262
273
  error: error.message,
263
- will_retry: true,
274
+ will_retry: !shouldFailFast,
275
+ attempt: this.initialAttempts,
276
+ has_retry_policy: this.connectionRetryPolicy !== null,
264
277
  });
265
- if (!this.hadSuccessfulAttach && error instanceof FameConnectError) {
278
+ if (shouldFailFast && error instanceof FameConnectError) {
266
279
  throw error;
267
280
  }
268
281
  }
@@ -277,11 +290,13 @@ export class UpstreamSessionManager extends TaskSpawner {
277
290
  else {
278
291
  logger.warning('upstream_link_closed', {
279
292
  error: err.message,
280
- will_retry: true,
293
+ will_retry: !shouldFailFast,
294
+ attempt: this.initialAttempts,
295
+ has_retry_policy: this.connectionRetryPolicy !== null,
281
296
  exc_info: true,
282
297
  });
283
298
  }
284
- if (!this.hadSuccessfulAttach) {
299
+ if (shouldFailFast) {
285
300
  throw error;
286
301
  }
287
302
  }
@@ -289,52 +304,77 @@ export class UpstreamSessionManager extends TaskSpawner {
289
304
  }
290
305
  }
291
306
  }
307
+ /**
308
+ * Determine whether to fail immediately or continue retrying.
309
+ * Returns true if we should throw the error instead of retrying.
310
+ */
311
+ shouldFailFastOnError(error) {
312
+ // If no policy is configured, use legacy behavior (fail-fast after first attempt)
313
+ if (!this.connectionRetryPolicy) {
314
+ // After first successful attach, always retry (existing behavior)
315
+ if (this.hadSuccessfulAttach) {
316
+ return false;
317
+ }
318
+ // Without a policy, fail on first error
319
+ return true;
320
+ }
321
+ // Delegate decision to the policy
322
+ const shouldRetry = this.connectionRetryPolicy.shouldRetry({
323
+ hadSuccessfulAttach: this.hadSuccessfulAttach,
324
+ attemptNumber: this.initialAttempts,
325
+ error,
326
+ });
327
+ return !shouldRetry;
328
+ }
292
329
  async applyBackoff(delay) {
293
330
  const jitter = Math.random() * delay;
294
- await this.sleepWithStop(delay + jitter);
331
+ const wasWoken = await this.sleepWithStop(delay + jitter);
332
+ // If sleep was interrupted by visibility change (user returned to tab),
333
+ // reset backoff to initial delay for immediate retry with fresh backoff
334
+ if (wasWoken) {
335
+ logger.debug('backoff_reset_on_visibility_change', {
336
+ previous_delay: delay,
337
+ new_delay: UpstreamSessionManager.BACKOFF_INITIAL,
338
+ });
339
+ return UpstreamSessionManager.BACKOFF_INITIAL;
340
+ }
295
341
  return Math.min(delay * 2, UpstreamSessionManager.BACKOFF_CAP);
296
342
  }
343
+ /**
344
+ * Sleep for the specified duration, but can be interrupted by stop or wake events.
345
+ * @returns true if interrupted by wake event (e.g., visibility change), false otherwise
346
+ */
297
347
  async sleepWithStop(delaySeconds) {
298
348
  if (delaySeconds <= 0) {
299
- return;
300
- }
301
- // If the document is visible, cap the backoff delay to improve UX
302
- // This ensures that if the user is watching, we retry quickly (e.g. 1s)
303
- // instead of waiting for the full exponential backoff (up to 30s).
304
- let effectiveDelay = delaySeconds;
305
- if (typeof document !== 'undefined' &&
306
- document.visibilityState === 'visible') {
307
- effectiveDelay = Math.min(delaySeconds, 1.0);
308
- if (effectiveDelay < delaySeconds) {
309
- logger.debug('sleep_reduced_document_visible', {
310
- original: delaySeconds,
311
- new: effectiveDelay,
312
- });
313
- }
349
+ return false;
314
350
  }
351
+ // Check if wake event is already set (e.g., visibility just changed)
315
352
  if (this.wakeEvent.isSet()) {
316
353
  this.wakeEvent.clear();
317
- return;
354
+ logger.debug('sleep_skipped_wake_event_pending');
355
+ return true;
318
356
  }
319
357
  let timeout;
320
358
  const sleepPromise = new Promise((resolve) => {
321
359
  timeout = setTimeout(() => {
322
360
  timeout = undefined;
323
361
  resolve();
324
- }, effectiveDelay * 1000);
362
+ }, delaySeconds * 1000);
325
363
  });
326
364
  await Promise.race([
327
365
  sleepPromise,
328
366
  this.stopEvent.wait(),
329
367
  this.wakeEvent.wait(),
330
368
  ]);
331
- if (this.wakeEvent.isSet()) {
369
+ const wasWoken = this.wakeEvent.isSet();
370
+ if (wasWoken) {
332
371
  logger.debug('sleep_interrupted_by_wake_event');
333
372
  this.wakeEvent.clear();
334
373
  }
335
374
  if (timeout !== undefined) {
336
375
  clearTimeout(timeout);
337
376
  }
377
+ return wasWoken;
338
378
  }
339
379
  getNodeAttachGrant(connectionGrants) {
340
380
  if (!connectionGrants) {
@@ -154,6 +154,7 @@ export class Sentinel extends FameNode {
154
154
  this.maxAttachTtlSec = opts.maxAttachTtlSec ?? null;
155
155
  this.requestedLogicals = opts.requestedLogicals ?? [];
156
156
  this.attachClient = opts.attachClient ?? null;
157
+ this.connectionRetryPolicy = opts.connectionRetryPolicy ?? null;
157
158
  this.nodeAttachFrameHandler = new NodeAttachFrameHandler({
158
159
  routingNode: this,
159
160
  routeManager: this.routeManager,
@@ -703,6 +704,7 @@ export class Sentinel extends FameNode {
703
704
  onAttach: (info, connector) => this.onNodeAttachToPeer(info, connector),
704
705
  onEpochChange: (epoch) => this.onEpochChange(epoch),
705
706
  onWelcome: async () => undefined,
707
+ retryPolicy: this.connectionRetryPolicy,
706
708
  });
707
709
  await sessionManager.start();
708
710
  const systemId = sessionManager.systemId;
package/dist/esm/node.js CHANGED
@@ -1,10 +1,21 @@
1
1
  import './naylence/fame/connector/websocket-connector-node-ssl.js';
2
+ import plugin from './plugin.js';
2
3
  // Ensure Node-specific registrations (storage, sqlite, etc.) happen before
3
4
  // the isomorphic exports are evaluated. Some factories (SQLite) must be
4
5
  // registered early so configuration parsing that happens during runtime
5
6
  // initialization can resolve the requested storage profiles.
6
7
  import './naylence/fame/storage/node-index.js'; // Side-effect: registers SQLite profiles
7
- // Auto-register the runtime plugin if we are in a Node.js environment
8
+ // Always register the plugin directly. This ensures it is initialized even if
9
+ // the dynamic import mechanism (used by FAME_PLUGINS) fails or is not used.
10
+ (async () => {
11
+ try {
12
+ await plugin.register();
13
+ }
14
+ catch (err) {
15
+ console.error('[naylence-runtime] Failed to auto-register plugin:', err);
16
+ }
17
+ })();
18
+ // Auto-register the runtime plugin in FAME_PLUGINS for child processes
8
19
  if (typeof process !== 'undefined' && process.env) {
9
20
  const pluginName = '@naylence/runtime';
10
21
  const current = process.env.FAME_PLUGINS || '';
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated during build - do not edit manually
2
- // Generated from package.json version: 0.3.14
2
+ // Generated from package.json version: 0.3.16
3
3
  /**
4
4
  * The package version, injected at build time.
5
5
  * @internal
6
6
  */
7
- export const VERSION = '0.3.14';
7
+ export const VERSION = '0.3.16';