@earendil-works/chord 0.0.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.
- package/README.md +205 -0
- package/dist/api.d.ts +15 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +72 -0
- package/dist/api.js.map +1 -0
- package/dist/bundler.d.ts +6 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/bundler.js +3 -0
- package/dist/bundler.js.map +1 -0
- package/dist/context/index.d.ts +24 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +97 -0
- package/dist/context/index.js.map +1 -0
- package/dist/delta/index.d.ts +119 -0
- package/dist/delta/index.d.ts.map +1 -0
- package/dist/delta/index.js +1213 -0
- package/dist/delta/index.js.map +1 -0
- package/dist/facets/host.d.ts +12 -0
- package/dist/facets/host.d.ts.map +1 -0
- package/dist/facets/host.js +763 -0
- package/dist/facets/host.js.map +1 -0
- package/dist/facets/loader.d.ts +3 -0
- package/dist/facets/loader.d.ts.map +1 -0
- package/dist/facets/loader.js +5 -0
- package/dist/facets/loader.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +4 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +57 -0
- package/dist/json.js.map +1 -0
- package/dist/node/bundle-loader.d.ts +30 -0
- package/dist/node/bundle-loader.d.ts.map +1 -0
- package/dist/node/bundle-loader.js +377 -0
- package/dist/node/bundle-loader.js.map +1 -0
- package/dist/node/bundle.d.ts +26 -0
- package/dist/node/bundle.d.ts.map +1 -0
- package/dist/node/bundle.js +183 -0
- package/dist/node/bundle.js.map +1 -0
- package/dist/node/manifest.d.ts +36 -0
- package/dist/node/manifest.d.ts.map +1 -0
- package/dist/node/manifest.js +6 -0
- package/dist/node/manifest.js.map +1 -0
- package/dist/node/package.d.ts +15 -0
- package/dist/node/package.d.ts.map +1 -0
- package/dist/node/package.js +199 -0
- package/dist/node/package.js.map +1 -0
- package/dist/node.d.ts +5 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +3 -0
- package/dist/node.js.map +1 -0
- package/dist/services/consumer.d.ts +11 -0
- package/dist/services/consumer.d.ts.map +1 -0
- package/dist/services/consumer.js +537 -0
- package/dist/services/consumer.js.map +1 -0
- package/dist/services/errors.d.ts +8 -0
- package/dist/services/errors.d.ts.map +1 -0
- package/dist/services/errors.js +22 -0
- package/dist/services/errors.js.map +1 -0
- package/dist/services/handle.d.ts +15 -0
- package/dist/services/handle.d.ts.map +1 -0
- package/dist/services/handle.js +99 -0
- package/dist/services/handle.js.map +1 -0
- package/dist/services/instances.d.ts +25 -0
- package/dist/services/instances.d.ts.map +1 -0
- package/dist/services/instances.js +137 -0
- package/dist/services/instances.js.map +1 -0
- package/dist/services/loopback.d.ts +5 -0
- package/dist/services/loopback.d.ts.map +1 -0
- package/dist/services/loopback.js +15 -0
- package/dist/services/loopback.js.map +1 -0
- package/dist/services/provider.d.ts +37 -0
- package/dist/services/provider.d.ts.map +1 -0
- package/dist/services/provider.js +472 -0
- package/dist/services/provider.js.map +1 -0
- package/dist/services/state-codec.d.ts +15 -0
- package/dist/services/state-codec.d.ts.map +1 -0
- package/dist/services/state-codec.js +115 -0
- package/dist/services/state-codec.js.map +1 -0
- package/dist/services/state-internals.d.ts +11 -0
- package/dist/services/state-internals.d.ts.map +1 -0
- package/dist/services/state-internals.js +10 -0
- package/dist/services/state-internals.js.map +1 -0
- package/dist/services/state.d.ts +23 -0
- package/dist/services/state.d.ts.map +1 -0
- package/dist/services/state.js +124 -0
- package/dist/services/state.js.map +1 -0
- package/dist/services/wire.d.ts +60 -0
- package/dist/services/wire.d.ts.map +1 -0
- package/dist/services/wire.js +173 -0
- package/dist/services/wire.js.map +1 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consumer.d.ts","sourceRoot":"","sources":["../../src/services/consumer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACX,OAAO,EAEP,oBAAoB,EACpB,2BAA2B,EAE3B,OAAO,EAMP,MAAM,aAAa,CAAC;AA0ZrB,qBAAa,wBAAyB,YAAW,oBAAoB;;IAapE,YAAY,OAAO,EAAE,2BAA2B,EAQ/C;IAED,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CA2B7B;IAED,OAAO,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,IAAI,CAsB3G;IAEK,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkB3C;IAEK,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CA8B5D;IAEK,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAoB7C;CA6DD","sourcesContent":["import { awaitWithContext, BACKGROUND_CONTEXT } from \"../context/index.ts\";\nimport type { Op } from \"../delta/index.ts\";\nimport type {\n\tContext,\n\tJsonValue,\n\tRemoteServiceBinding,\n\tRemoteServiceBindingOptions,\n\tRemoteServiceTransport,\n\tService,\n\tServiceInstanceAddress,\n\tServiceInstanceSnapshot,\n\tServiceMemberSnapshot,\n\tServiceProviderUpdate,\n\tServiceSubscription,\n} from \"../types.ts\";\nimport { RemoteServiceError } from \"./errors.ts\";\nimport { ServiceSlot } from \"./handle.ts\";\nimport { InstanceDirectory, type InstanceDirectoryEntry } from \"./instances.ts\";\nimport { ReplicatedStateReplica, serviceDeliveryContext } from \"./state.ts\";\n\ntype ErrorReporter = (error: Error) => void;\ntype ServiceMemberKind = ServiceMemberSnapshot[\"kind\"];\n\nclass MemberSlot {\n\treadonly #serviceId: string;\n\treadonly #member: string;\n\treadonly #invoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>;\n\treadonly #state: ReplicatedStateReplica;\n\treadonly #isActive: () => boolean;\n\treadonly #assertAccess: () => void;\n\treadonly value: unknown;\n\t#kind: ServiceMemberKind | undefined;\n\t#expectedKind: ServiceMemberKind | undefined;\n\n\tconstructor(\n\t\tserviceId: string,\n\t\tmember: string,\n\t\tinvoke: (args: readonly JsonValue[], context: Context) => Promise<JsonValue | undefined>,\n\t\tisActive: () => boolean,\n\t\tassertAccess: () => void,\n\t\treportError: ErrorReporter,\n\t) {\n\t\tthis.#serviceId = serviceId;\n\t\tthis.#member = member;\n\t\tthis.#invoke = invoke;\n\t\tthis.#isActive = isActive;\n\t\tthis.#assertAccess = assertAccess;\n\t\tthis.#state = new ReplicatedStateReplica(reportError);\n\t\tconst callable = (): void => {};\n\t\tthis.value = new Proxy(callable, {\n\t\t\tapply: (_target, _thisArg, args) => this.#call(args),\n\t\t\tget: (_target, property) => {\n\t\t\t\tif (property === \"value\") {\n\t\t\t\t\tthis.#assertAccess();\n\t\t\t\t\tthis.#expect(\"state\");\n\t\t\t\t\treturn this.#state.value;\n\t\t\t\t}\n\t\t\t\tif (property === \"subscribe\") return this.#subscribe.bind(this);\n\t\t\t\tif (property === Symbol.toStringTag) return \"RemoteServiceMember\";\n\t\t\t\tif (property === \"then\") return undefined;\n\t\t\t\treturn undefined;\n\t\t\t},\n\t\t});\n\t}\n\n\tsetDescription(kind: ServiceMemberKind): void {\n\t\tif (this.#kind !== undefined && this.#kind !== kind) {\n\t\t\tthrow new Error(`Remote service member ${this.#serviceId}.${this.#member} changed kind`);\n\t\t}\n\t\tthis.#kind = kind;\n\t\tif (this.#expectedKind !== undefined && this.#expectedKind !== kind) {\n\t\t\tthrow new RemoteServiceError(\n\t\t\t\t\"service_member_mismatch\",\n\t\t\t\t`Remote service member ${this.#serviceId}.${this.#member} is ${kind}, not ${this.#expectedKind}`,\n\t\t\t);\n\t\t}\n\t}\n\n\thydrate(sequence: number, ops: readonly Op[], context: Context): void {\n\t\tthis.setDescription(\"state\");\n\t\tthis.#state.hydrate(sequence, ops, context);\n\t}\n\n\tupdate(sequence: number, ops: readonly Op[], context: Context): void {\n\t\tthis.setDescription(\"state\");\n\t\tthis.#state.update(sequence, ops, context);\n\t}\n\n\tclear(): void {\n\t\tthis.#state.clear();\n\t}\n\n\t#subscribe(listener: (value: JsonValue, context: Context) => void): () => void {\n\t\tthis.#assertAccess();\n\t\tif (typeof listener !== \"function\") {\n\t\t\tthrow new TypeError(\"Replicated state subscription listener must be a function\");\n\t\t}\n\t\tthis.#expect(\"state\");\n\t\treturn this.#state.subscribe(listener);\n\t}\n\n\t#expect(kind: ServiceMemberKind): void {\n\t\tif (this.#expectedKind !== undefined && this.#expectedKind !== kind) {\n\t\t\tthrow new RemoteServiceError(\n\t\t\t\t\"service_member_mismatch\",\n\t\t\t\t`Remote service member ${this.#serviceId}.${this.#member} was used as two different kinds`,\n\t\t\t);\n\t\t}\n\t\tthis.#expectedKind = kind;\n\t\tif (this.#kind !== undefined && this.#kind !== kind) {\n\t\t\tthrow new RemoteServiceError(\n\t\t\t\t\"service_member_mismatch\",\n\t\t\t\t`Remote service member ${this.#serviceId}.${this.#member} is ${this.#kind}, not ${kind}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t#call(args: unknown[]): Promise<JsonValue | undefined> {\n\t\tthis.#assertAccess();\n\t\tthis.#expect(\"method\");\n\t\tif (!this.#isActive()) {\n\t\t\treturn Promise.reject(\n\t\t\t\tnew RemoteServiceError(\"service_stale_instance\", `Remote service ${this.#serviceId} binding is closed`),\n\t\t\t);\n\t\t}\n\t\tconst context = args.at(-1);\n\t\tif (!isContext(context)) {\n\t\t\treturn Promise.reject(\n\t\t\t\tnew RemoteServiceError(\n\t\t\t\t\t\"service_invalid_value\",\n\t\t\t\t\t`Remote service method ${this.#serviceId}.${this.#member} requires a trailing Context`,\n\t\t\t\t),\n\t\t\t);\n\t\t}\n\t\tconst businessArgs = args.slice(0, -1) as JsonValue[];\n\t\treturn this.#invoke(businessArgs, context);\n\t}\n}\n\nclass ServiceFacade {\n\treadonly #serviceId: string;\n\treadonly #address: ServiceInstanceAddress | undefined;\n\treadonly #transport: RemoteServiceTransport;\n\treadonly #reportError: ErrorReporter;\n\treadonly #slots = new Map<string, MemberSlot>();\n\treadonly #descriptions = new Map<string, ServiceMemberKind>();\n\treadonly #isActive: () => boolean;\n\treadonly #assertAccess: () => void;\n\treadonly proxy: object;\n\n\tconstructor(\n\t\tserviceId: string,\n\t\taddress: ServiceInstanceAddress | undefined,\n\t\ttransport: RemoteServiceTransport,\n\t\tisActive: () => boolean,\n\t\tassertAccess: () => void,\n\t\treportError: ErrorReporter,\n\t) {\n\t\tthis.#serviceId = serviceId;\n\t\tthis.#address = address;\n\t\tthis.#transport = transport;\n\t\tthis.#isActive = isActive;\n\t\tthis.#assertAccess = assertAccess;\n\t\tthis.#reportError = reportError;\n\t\tthis.proxy = new Proxy(Object.create(null) as object, {\n\t\t\tget: (_target, property) => {\n\t\t\t\tif (typeof property !== \"string\") return undefined;\n\t\t\t\treturn this.#slot(property).value;\n\t\t\t},\n\t\t});\n\t}\n\n\tinstall(snapshot: ServiceInstanceSnapshot, context: Context): void {\n\t\tif (!sameAddress(snapshot.instance, this.#address))\n\t\t\tthrow new Error(\"Remote service snapshot has the wrong address\");\n\t\tconst members = validateMembers(snapshot.members);\n\t\tfor (const name of this.#slots.keys()) {\n\t\t\tif (!members.has(name)) {\n\t\t\t\tthrow new RemoteServiceError(\n\t\t\t\t\t\"service_member_not_found\",\n\t\t\t\t\t`Unknown remote service member ${this.#serviceId}.${name}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\tthis.#descriptions.clear();\n\t\tfor (const [name, member] of members) this.#descriptions.set(name, member.kind);\n\t\tfor (const member of members.values()) {\n\t\t\tconst slot = this.#slots.get(member.name);\n\t\t\tif (member.kind === \"state\") {\n\t\t\t\t(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.ops, context);\n\t\t\t} else {\n\t\t\t\tslot?.setDescription(member.kind);\n\t\t\t}\n\t\t}\n\t}\n\n\tupdate(member: string, sequence: number, ops: readonly Op[], context: Context): void {\n\t\tif (this.#descriptions.get(member) !== \"state\") {\n\t\t\tthrow new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);\n\t\t}\n\t\tthis.#slot(member).update(sequence, ops, context);\n\t}\n\n\tclear(): void {\n\t\tfor (const slot of this.#slots.values()) slot.clear();\n\t}\n\n\t#slot(member: string): MemberSlot {\n\t\tlet slot = this.#slots.get(member);\n\t\tif (slot !== undefined) return slot;\n\t\tslot = new MemberSlot(\n\t\t\tthis.#serviceId,\n\t\t\tmember,\n\t\t\t(args, context) =>\n\t\t\t\tthis.#transport.invoke(\n\t\t\t\t\t{\n\t\t\t\t\t\tserviceId: this.#serviceId,\n\t\t\t\t\t\t...(this.#address === undefined ? {} : { instance: this.#address }),\n\t\t\t\t\t\tmember,\n\t\t\t\t\t\targs,\n\t\t\t\t\t},\n\t\t\t\t\tcontext,\n\t\t\t\t),\n\t\t\tthis.#isActive,\n\t\t\tthis.#assertAccess,\n\t\t\tthis.#reportError,\n\t\t);\n\t\tconst kind = this.#descriptions.get(member);\n\t\tif (kind !== undefined) slot.setDescription(kind);\n\t\tthis.#slots.set(member, slot);\n\t\treturn slot;\n\t}\n}\n\ninterface SingletonBinding {\n\tfacade: ServiceFacade;\n\tsubscription?: ServiceSubscription;\n\tstarting?: Promise<void>;\n\tactive: boolean;\n\trevision: number;\n}\n\ninterface KeyedInstance extends InstanceDirectoryEntry {\n\treadonly facade: ServiceFacade;\n}\n\nclass KeyedBinding<T> {\n\treadonly #service: Service<T>;\n\treadonly #transport: RemoteServiceTransport;\n\treadonly #reportError: ErrorReporter;\n\treadonly #assertAccess: () => void;\n\treadonly #onEmpty: () => void;\n\treadonly #instances: InstanceDirectory<KeyedInstance>;\n\t#subscription: ServiceSubscription | undefined;\n\t#starting: Promise<void> | undefined;\n\t#closed = false;\n\t#bound: boolean;\n\t#revision = 0;\n\n\tconstructor(\n\t\tservice: Service<T>,\n\t\ttransport: RemoteServiceTransport,\n\t\treportError: ErrorReporter,\n\t\tassertAccess: () => void,\n\t\tonEmpty: () => void,\n\t\tbound: boolean,\n\t) {\n\t\tthis.#service = service;\n\t\tthis.#transport = transport;\n\t\tthis.#reportError = reportError;\n\t\tthis.#assertAccess = assertAccess;\n\t\tthis.#onEmpty = onEmpty;\n\t\tthis.#bound = bound;\n\t\tthis.#instances = new InstanceDirectory({ ready: false, onError: reportError });\n\t}\n\n\tobserve(handler: (service: T, context: Context) => void | Promise<void>): () => void {\n\t\tif (this.#closed) throw new Error(\"Remote keyed service binding is closed\");\n\t\tlet stopped = false;\n\t\tconst stop = this.#instances.observe<T>((service, context) => {\n\t\t\tconst slot = new ServiceSlot(this.#service.id, true);\n\t\t\tvoid slot.bind(service as object);\n\t\t\treturn handler(\n\t\t\t\tslot.view<T>(() => {\n\t\t\t\t\tthis.#assertAccess();\n\t\t\t\t\tif (stopped || context.abortSignal?.aborted) {\n\t\t\t\t\t\tthrow new RemoteServiceError(\n\t\t\t\t\t\t\t\"service_stale_instance\",\n\t\t\t\t\t\t\t`Remote service ${this.#service.id} observation is closed`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}),\n\t\t\t\tcontext,\n\t\t\t);\n\t\t});\n\t\tif (this.#bound && this.#starting === undefined) {\n\t\t\tconst revision = this.#revision;\n\t\t\tconst starting = this.#start(revision);\n\t\t\tthis.#starting = starting;\n\t\t\tvoid starting.catch((error: unknown) => {\n\t\t\t\tif (!this.#closed && this.#revision === revision && this.#bound) this.#reportError(toError(error));\n\t\t\t});\n\t\t}\n\t\treturn () => {\n\t\t\tif (stopped) return;\n\t\t\tstopped = true;\n\t\t\tstop();\n\t\t\tif (this.#instances.observerCount === 0) this.#onEmpty();\n\t\t};\n\t}\n\n\tasync rebind(bound: boolean, context: Context): Promise<void> {\n\t\tif (this.#closed) return;\n\t\tthis.#bound = bound;\n\t\tconst revision = ++this.#revision;\n\t\tawait this.#reset(context, false);\n\t\tif (this.#closed || this.#revision !== revision || this.#bound !== bound) return;\n\t\tif (bound && this.#instances.observerCount > 0) {\n\t\t\tconst starting = this.#start(revision);\n\t\t\tthis.#starting = starting;\n\t\t\tawait starting;\n\t\t}\n\t}\n\n\tready(): Promise<void> {\n\t\treturn this.#starting ?? Promise.resolve();\n\t}\n\n\tasync close(context: Context): Promise<void> {\n\t\tif (this.#closed) return;\n\t\tthis.#closed = true;\n\t\tthis.#revision += 1;\n\t\tawait this.#reset(context, true);\n\t\tthis.#instances.dispose();\n\t}\n\n\tasync #reset(context: Context, waitForStarting: boolean): Promise<void> {\n\t\tthis.#instances.reset();\n\t\tconst starting = this.#starting;\n\t\tthis.#starting = undefined;\n\t\tconst subscription = this.#subscription;\n\t\tthis.#subscription = undefined;\n\t\tawait Promise.all([\n\t\t\twaitForStarting ? starting?.catch(() => {}) : undefined,\n\t\t\tsubscription === undefined ? undefined : subscription.close(context),\n\t\t]);\n\t}\n\n\tasync #start(revision: number): Promise<void> {\n\t\tconst subscription = await this.#transport.subscribe(\n\t\t\tthis.#service.id,\n\t\t\t\"keyed\",\n\t\t\t(update, context) => {\n\t\t\t\tif (this.#revision === revision) this.#update(update, context);\n\t\t\t},\n\t\t\tBACKGROUND_CONTEXT,\n\t\t);\n\t\tif (this.#closed || !this.#bound || this.#revision !== revision) {\n\t\t\tawait subscription.close(BACKGROUND_CONTEXT);\n\t\t\treturn;\n\t\t}\n\t\tthis.#subscription = subscription;\n\t\tif (subscription.snapshot.mode !== \"keyed\" || subscription.snapshot.serviceId !== this.#service.id) {\n\t\t\tthrow new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);\n\t\t}\n\t\tfor (const snapshot of subscription.snapshot.instances) this.#spawn(snapshot, serviceDeliveryContext());\n\t\tsubscription.activate();\n\t\tthis.#instances.ready();\n\t}\n\n\t#update(update: ServiceProviderUpdate, context: Context): void {\n\t\tif (this.#closed) return;\n\t\ttry {\n\t\t\tswitch (update.type) {\n\t\t\t\tcase \"unavailable\":\n\t\t\t\tcase \"replaced\":\n\t\t\t\t\tthrow new Error(\"Keyed service received a singleton lifecycle update\");\n\t\t\t\tcase \"spawned\":\n\t\t\t\t\tthis.#spawn(update.instance, context);\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"closed\": {\n\t\t\t\t\tconst instance = this.#instances.get(update.instance.key);\n\t\t\t\t\tif (instance?.generation === update.instance.generation) this.#instances.remove(instance);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"state\": {\n\t\t\t\t\tif (update.instance === undefined) throw new Error(\"Keyed state update has no instance address\");\n\t\t\t\t\tconst instance = this.#instances.get(update.instance.key);\n\t\t\t\t\tif (instance?.generation !== update.instance.generation) return;\n\t\t\t\t\tinstance.facade.update(update.member, update.sequence, update.ops, context);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.#reportError(toError(error));\n\t\t}\n\t}\n\n\t#spawn(snapshot: ServiceInstanceSnapshot, context: Context): void {\n\t\tconst address = snapshot.instance;\n\t\tif (address === undefined) throw new Error(\"Keyed service instance snapshot has no address\");\n\t\tlet active = true;\n\t\tconst facade = new ServiceFacade(\n\t\t\tthis.#service.id,\n\t\t\taddress,\n\t\t\tthis.#transport,\n\t\t\t() => active && !this.#closed,\n\t\t\tthis.#assertAccess,\n\t\t\tthis.#reportError,\n\t\t);\n\t\tfacade.install(snapshot, context);\n\t\tthis.#instances.replace({\n\t\t\tkey: address.key,\n\t\t\tgeneration: address.generation,\n\t\t\tservice: facade.proxy,\n\t\t\tfacade,\n\t\t\tdeactivate: () => {\n\t\t\t\tactive = false;\n\t\t\t\tfacade.clear();\n\t\t\t},\n\t\t});\n\t}\n}\n\nexport class RemoteServiceBindingImpl implements RemoteServiceBinding {\n\treadonly #transport: RemoteServiceTransport;\n\treadonly #allowlist = new Set<string>();\n\treadonly #reportError: ErrorReporter;\n\treadonly #modes = new Map<string, \"singleton\" | \"keyed\">();\n\treadonly #assertAccess: () => void;\n\treadonly #singletons = new Map<string, SingletonBinding>();\n\treadonly #keyed = new Map<string, KeyedBinding<unknown>>();\n\t#bound: boolean;\n\t#readinessRevision = 0;\n\t#bindingTransition = Promise.resolve();\n\t#disposed = false;\n\n\tconstructor(options: RemoteServiceBindingOptions) {\n\t\tthis.#transport = options.transport;\n\t\tconst ids = options.services.map(({ id }) => id);\n\t\tif (new Set(ids).size !== ids.length) throw new TypeError(\"Remote service binding has duplicate service IDs\");\n\t\tfor (const id of ids) this.#allowlist.add(id);\n\t\tthis.#reportError = options.onError ?? (() => {});\n\t\tthis.#assertAccess = options.assertAccess ?? (() => {});\n\t\tthis.#bound = options.bound ?? true;\n\t}\n\n\tuse<T>(service: Service<T>): T {\n\t\tthis.#assertRemotable(service);\n\t\tthis.#assertAvailable(service.id, \"singleton\");\n\t\tlet binding = this.#singletons.get(service.id);\n\t\tif (binding !== undefined) return binding.facade.proxy as T;\n\t\tbinding = { facade: undefined as unknown as ServiceFacade, active: true, revision: 0 };\n\t\tbinding.facade = new ServiceFacade(\n\t\t\tservice.id,\n\t\t\tundefined,\n\t\t\tthis.#transport,\n\t\t\t() => binding!.active && !this.#disposed && this.#bound,\n\t\t\t() => this.#assertHandleAccess(),\n\t\t\tthis.#reportError,\n\t\t);\n\t\tthis.#singletons.set(service.id, binding);\n\t\tthis.#readinessRevision += 1;\n\t\tif (this.#bound) {\n\t\t\tconst revision = binding.revision;\n\t\t\tconst starting = this.#startSingleton(service.id, binding, revision);\n\t\t\tbinding.starting = starting;\n\t\t\tvoid starting.catch((error: unknown) => {\n\t\t\t\tif (binding.active && binding.revision === revision && !this.#disposed && this.#bound) {\n\t\t\t\t\tthis.#reportError(toError(error));\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t\treturn binding.facade.proxy as T;\n\t}\n\n\tobserve<T>(service: Service<T>, handler: (service: T, context: Context) => void | Promise<void>): () => void {\n\t\tthis.#assertRemotable(service);\n\t\tthis.#assertAvailable(service.id, \"keyed\");\n\t\tlet binding = this.#keyed.get(service.id) as KeyedBinding<T> | undefined;\n\t\tif (binding === undefined) {\n\t\t\tbinding = new KeyedBinding(\n\t\t\t\tservice,\n\t\t\t\tthis.#transport,\n\t\t\t\tthis.#reportError,\n\t\t\t\t() => this.#assertHandleAccess(),\n\t\t\t\t() => {\n\t\t\t\t\tif (this.#keyed.get(service.id) !== binding) return;\n\t\t\t\t\tthis.#keyed.delete(service.id);\n\t\t\t\t\tthis.#readinessRevision += 1;\n\t\t\t\t\tvoid binding!.close(BACKGROUND_CONTEXT).catch(this.#reportError);\n\t\t\t\t},\n\t\t\t\tthis.#bound,\n\t\t\t);\n\t\t\tthis.#keyed.set(service.id, binding as KeyedBinding<unknown>);\n\t\t\tthis.#readinessRevision += 1;\n\t\t}\n\t\treturn binding.observe(handler);\n\t}\n\n\tasync ready(context: Context): Promise<void> {\n\t\tif (this.#disposed) throw new Error(\"Remote service binding is disposed\");\n\t\twhile (true) {\n\t\t\tconst revision = this.#readinessRevision;\n\t\t\tconst starts = [\n\t\t\t\tthis.#bindingTransition,\n\t\t\t\t...[...this.#singletons.values()].flatMap((binding) =>\n\t\t\t\t\tbinding.starting === undefined ? [] : [binding.starting],\n\t\t\t\t),\n\t\t\t\t...[...this.#keyed.values()].map((binding) => binding.ready()),\n\t\t\t];\n\t\t\tawait awaitWithContext(\n\t\t\t\tPromise.all(starts).then(() => undefined),\n\t\t\t\tcontext,\n\t\t\t);\n\t\t\tif (this.#disposed) throw new Error(\"Remote service binding is disposed\");\n\t\t\tif (revision === this.#readinessRevision) return;\n\t\t}\n\t}\n\n\tasync rebind(bound: boolean, context: Context): Promise<void> {\n\t\tif (this.#disposed) throw new Error(\"Remote service binding is disposed\");\n\t\tthis.#bound = bound;\n\t\tthis.#readinessRevision += 1;\n\t\tconst transitions: Promise<void>[] = [];\n\t\tfor (const [serviceId, binding] of this.#singletons) {\n\t\t\tbinding.revision += 1;\n\t\t\tbinding.facade.clear();\n\t\t\tconst subscription = binding.subscription;\n\t\t\tdelete binding.subscription;\n\t\t\tconst revision = binding.revision;\n\t\t\tconst starting = (async () => {\n\t\t\t\tawait subscription?.close(context);\n\t\t\t\tif (bound) await this.#startSingleton(serviceId, binding, revision);\n\t\t\t})();\n\t\t\tbinding.starting = starting;\n\t\t\ttransitions.push(starting);\n\t\t}\n\t\tfor (const binding of this.#keyed.values()) transitions.push(binding.rebind(bound, context));\n\t\tconst completion = (async () => {\n\t\t\tconst results = await Promise.allSettled(transitions);\n\t\t\tconst errors = results.filter((result): result is PromiseRejectedResult => result.status === \"rejected\");\n\t\t\tif (errors.length > 0)\n\t\t\t\tthrow new AggregateError(\n\t\t\t\t\terrors.map(({ reason }) => reason),\n\t\t\t\t\t\"Failed to rebind services\",\n\t\t\t\t);\n\t\t})();\n\t\tthis.#bindingTransition = completion;\n\t\tawait completion;\n\t}\n\n\tasync dispose(context: Context): Promise<void> {\n\t\tif (this.#disposed) return;\n\t\tthis.#disposed = true;\n\t\tconst closes: Promise<void>[] = [];\n\t\tfor (const binding of this.#singletons.values()) {\n\t\t\tbinding.active = false;\n\t\t\tbinding.facade.clear();\n\t\t\tif (binding.starting) closes.push(binding.starting.catch(() => {}));\n\t\t\tif (binding.subscription) closes.push(Promise.resolve(binding.subscription.close(context)));\n\t\t}\n\t\tfor (const binding of this.#keyed.values()) closes.push(binding.close(context));\n\t\tthis.#singletons.clear();\n\t\tthis.#keyed.clear();\n\t\tconst results = await Promise.allSettled(closes);\n\t\tconst errors = results.filter((result): result is PromiseRejectedResult => result.status === \"rejected\");\n\t\tif (errors.length > 0)\n\t\t\tthrow new AggregateError(\n\t\t\t\terrors.map(({ reason }) => reason),\n\t\t\t\t\"Failed to dispose services\",\n\t\t\t);\n\t}\n\n\tasync #startSingleton(serviceId: string, binding: SingletonBinding, revision: number): Promise<void> {\n\t\tconst subscription = await this.#transport.subscribe(\n\t\t\tserviceId,\n\t\t\t\"singleton\",\n\t\t\t(update, context) => {\n\t\t\t\tif (!binding.active || binding.revision !== revision) return;\n\t\t\t\ttry {\n\t\t\t\t\tif (update.type === \"unavailable\") {\n\t\t\t\t\t\tbinding.facade.clear();\n\t\t\t\t\t} else if (update.type === \"replaced\") {\n\t\t\t\t\t\tif (update.snapshot.instance !== undefined) {\n\t\t\t\t\t\t\tthrow new Error(\"Singleton replacement has an instance address\");\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbinding.facade.install(update.snapshot, context);\n\t\t\t\t\t} else if (update.type === \"state\" && update.instance === undefined) {\n\t\t\t\t\t\tbinding.facade.update(update.member, update.sequence, update.ops, context);\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.#reportError(toError(error));\n\t\t\t\t}\n\t\t\t},\n\t\t\tBACKGROUND_CONTEXT,\n\t\t);\n\t\tif (!binding.active || this.#disposed || !this.#bound || binding.revision !== revision) {\n\t\t\tawait subscription.close(BACKGROUND_CONTEXT);\n\t\t\treturn;\n\t\t}\n\t\tbinding.subscription = subscription;\n\t\tconst snapshot = subscription.snapshot;\n\t\tif (snapshot.mode !== \"singleton\" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {\n\t\t\tthrow new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);\n\t\t}\n\t\tbinding.facade.install(snapshot.instances[0]!, serviceDeliveryContext());\n\t\tsubscription.activate();\n\t}\n\n\t#assertHandleAccess(): void {\n\t\tif (this.#disposed) throw new Error(\"Remote service binding is disposed\");\n\t\tthis.#assertAccess();\n\t}\n\n\t#assertRemotable(service: { readonly id: string; readonly local: boolean }): void {\n\t\tif (service.local) throw new RemoteServiceError(\"service_not_allowed\", `Service ${service.id} is process-local`);\n\t}\n\n\t#assertAvailable(serviceId: string, mode: \"singleton\" | \"keyed\"): void {\n\t\tif (this.#disposed) throw new Error(\"Remote service binding is disposed\");\n\t\tif (!this.#allowlist.has(serviceId)) {\n\t\t\tthrow new RemoteServiceError(\"service_not_allowed\", `Remote service ${serviceId} is not allowlisted`);\n\t\t}\n\t\tconst existing = this.#modes.get(serviceId);\n\t\tif (existing !== undefined && existing !== mode) {\n\t\t\tthrow new RemoteServiceError(\n\t\t\t\t\"service_mode_mismatch\",\n\t\t\t\t`Remote service ${serviceId} is already used as ${existing}`,\n\t\t\t);\n\t\t}\n\t\tthis.#modes.set(serviceId, mode);\n\t}\n}\n\nfunction validateMembers(members: readonly ServiceMemberSnapshot[]): ReadonlyMap<string, ServiceMemberSnapshot> {\n\tconst result = new Map<string, ServiceMemberSnapshot>();\n\tfor (const member of members) {\n\t\tif (member.name.length === 0 || result.has(member.name)) {\n\t\t\tthrow new Error(\"Remote service has invalid member descriptions\");\n\t\t}\n\t\tresult.set(member.name, member);\n\t}\n\treturn result;\n}\n\nfunction sameAddress(left: ServiceInstanceAddress | undefined, right: ServiceInstanceAddress | undefined): boolean {\n\tif (left === undefined || right === undefined) return left === right;\n\treturn left.key === right.key && left.generation === right.generation;\n}\n\nfunction isContext(value: unknown): value is Context {\n\tif (typeof value !== \"object\" || value === null) return false;\n\tconst candidate = value as Partial<Context>;\n\treturn typeof candidate.value === \"function\" && typeof candidate.toString === \"function\";\n}\n\nfunction toError(error: unknown): Error {\n\treturn error instanceof Error ? error : new Error(String(error));\n}\n"]}
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { awaitWithContext, BACKGROUND_CONTEXT } from "../context/index.js";
|
|
2
|
+
import { RemoteServiceError } from "./errors.js";
|
|
3
|
+
import { ServiceSlot } from "./handle.js";
|
|
4
|
+
import { InstanceDirectory } from "./instances.js";
|
|
5
|
+
import { ReplicatedStateReplica, serviceDeliveryContext } from "./state.js";
|
|
6
|
+
class MemberSlot {
|
|
7
|
+
#serviceId;
|
|
8
|
+
#member;
|
|
9
|
+
#invoke;
|
|
10
|
+
#state;
|
|
11
|
+
#isActive;
|
|
12
|
+
#assertAccess;
|
|
13
|
+
value;
|
|
14
|
+
#kind;
|
|
15
|
+
#expectedKind;
|
|
16
|
+
constructor(serviceId, member, invoke, isActive, assertAccess, reportError) {
|
|
17
|
+
this.#serviceId = serviceId;
|
|
18
|
+
this.#member = member;
|
|
19
|
+
this.#invoke = invoke;
|
|
20
|
+
this.#isActive = isActive;
|
|
21
|
+
this.#assertAccess = assertAccess;
|
|
22
|
+
this.#state = new ReplicatedStateReplica(reportError);
|
|
23
|
+
const callable = () => { };
|
|
24
|
+
this.value = new Proxy(callable, {
|
|
25
|
+
apply: (_target, _thisArg, args) => this.#call(args),
|
|
26
|
+
get: (_target, property) => {
|
|
27
|
+
if (property === "value") {
|
|
28
|
+
this.#assertAccess();
|
|
29
|
+
this.#expect("state");
|
|
30
|
+
return this.#state.value;
|
|
31
|
+
}
|
|
32
|
+
if (property === "subscribe")
|
|
33
|
+
return this.#subscribe.bind(this);
|
|
34
|
+
if (property === Symbol.toStringTag)
|
|
35
|
+
return "RemoteServiceMember";
|
|
36
|
+
if (property === "then")
|
|
37
|
+
return undefined;
|
|
38
|
+
return undefined;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
setDescription(kind) {
|
|
43
|
+
if (this.#kind !== undefined && this.#kind !== kind) {
|
|
44
|
+
throw new Error(`Remote service member ${this.#serviceId}.${this.#member} changed kind`);
|
|
45
|
+
}
|
|
46
|
+
this.#kind = kind;
|
|
47
|
+
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
|
|
48
|
+
throw new RemoteServiceError("service_member_mismatch", `Remote service member ${this.#serviceId}.${this.#member} is ${kind}, not ${this.#expectedKind}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
hydrate(sequence, ops, context) {
|
|
52
|
+
this.setDescription("state");
|
|
53
|
+
this.#state.hydrate(sequence, ops, context);
|
|
54
|
+
}
|
|
55
|
+
update(sequence, ops, context) {
|
|
56
|
+
this.setDescription("state");
|
|
57
|
+
this.#state.update(sequence, ops, context);
|
|
58
|
+
}
|
|
59
|
+
clear() {
|
|
60
|
+
this.#state.clear();
|
|
61
|
+
}
|
|
62
|
+
#subscribe(listener) {
|
|
63
|
+
this.#assertAccess();
|
|
64
|
+
if (typeof listener !== "function") {
|
|
65
|
+
throw new TypeError("Replicated state subscription listener must be a function");
|
|
66
|
+
}
|
|
67
|
+
this.#expect("state");
|
|
68
|
+
return this.#state.subscribe(listener);
|
|
69
|
+
}
|
|
70
|
+
#expect(kind) {
|
|
71
|
+
if (this.#expectedKind !== undefined && this.#expectedKind !== kind) {
|
|
72
|
+
throw new RemoteServiceError("service_member_mismatch", `Remote service member ${this.#serviceId}.${this.#member} was used as two different kinds`);
|
|
73
|
+
}
|
|
74
|
+
this.#expectedKind = kind;
|
|
75
|
+
if (this.#kind !== undefined && this.#kind !== kind) {
|
|
76
|
+
throw new RemoteServiceError("service_member_mismatch", `Remote service member ${this.#serviceId}.${this.#member} is ${this.#kind}, not ${kind}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
#call(args) {
|
|
80
|
+
this.#assertAccess();
|
|
81
|
+
this.#expect("method");
|
|
82
|
+
if (!this.#isActive()) {
|
|
83
|
+
return Promise.reject(new RemoteServiceError("service_stale_instance", `Remote service ${this.#serviceId} binding is closed`));
|
|
84
|
+
}
|
|
85
|
+
const context = args.at(-1);
|
|
86
|
+
if (!isContext(context)) {
|
|
87
|
+
return Promise.reject(new RemoteServiceError("service_invalid_value", `Remote service method ${this.#serviceId}.${this.#member} requires a trailing Context`));
|
|
88
|
+
}
|
|
89
|
+
const businessArgs = args.slice(0, -1);
|
|
90
|
+
return this.#invoke(businessArgs, context);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
class ServiceFacade {
|
|
94
|
+
#serviceId;
|
|
95
|
+
#address;
|
|
96
|
+
#transport;
|
|
97
|
+
#reportError;
|
|
98
|
+
#slots = new Map();
|
|
99
|
+
#descriptions = new Map();
|
|
100
|
+
#isActive;
|
|
101
|
+
#assertAccess;
|
|
102
|
+
proxy;
|
|
103
|
+
constructor(serviceId, address, transport, isActive, assertAccess, reportError) {
|
|
104
|
+
this.#serviceId = serviceId;
|
|
105
|
+
this.#address = address;
|
|
106
|
+
this.#transport = transport;
|
|
107
|
+
this.#isActive = isActive;
|
|
108
|
+
this.#assertAccess = assertAccess;
|
|
109
|
+
this.#reportError = reportError;
|
|
110
|
+
this.proxy = new Proxy(Object.create(null), {
|
|
111
|
+
get: (_target, property) => {
|
|
112
|
+
if (typeof property !== "string")
|
|
113
|
+
return undefined;
|
|
114
|
+
return this.#slot(property).value;
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
install(snapshot, context) {
|
|
119
|
+
if (!sameAddress(snapshot.instance, this.#address))
|
|
120
|
+
throw new Error("Remote service snapshot has the wrong address");
|
|
121
|
+
const members = validateMembers(snapshot.members);
|
|
122
|
+
for (const name of this.#slots.keys()) {
|
|
123
|
+
if (!members.has(name)) {
|
|
124
|
+
throw new RemoteServiceError("service_member_not_found", `Unknown remote service member ${this.#serviceId}.${name}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
this.#descriptions.clear();
|
|
128
|
+
for (const [name, member] of members)
|
|
129
|
+
this.#descriptions.set(name, member.kind);
|
|
130
|
+
for (const member of members.values()) {
|
|
131
|
+
const slot = this.#slots.get(member.name);
|
|
132
|
+
if (member.kind === "state") {
|
|
133
|
+
(slot ?? this.#slot(member.name)).hydrate(member.sequence, member.ops, context);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
slot?.setDescription(member.kind);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
update(member, sequence, ops, context) {
|
|
141
|
+
if (this.#descriptions.get(member) !== "state") {
|
|
142
|
+
throw new Error(`Remote service update targets non-state member ${this.#serviceId}.${member}`);
|
|
143
|
+
}
|
|
144
|
+
this.#slot(member).update(sequence, ops, context);
|
|
145
|
+
}
|
|
146
|
+
clear() {
|
|
147
|
+
for (const slot of this.#slots.values())
|
|
148
|
+
slot.clear();
|
|
149
|
+
}
|
|
150
|
+
#slot(member) {
|
|
151
|
+
let slot = this.#slots.get(member);
|
|
152
|
+
if (slot !== undefined)
|
|
153
|
+
return slot;
|
|
154
|
+
slot = new MemberSlot(this.#serviceId, member, (args, context) => this.#transport.invoke({
|
|
155
|
+
serviceId: this.#serviceId,
|
|
156
|
+
...(this.#address === undefined ? {} : { instance: this.#address }),
|
|
157
|
+
member,
|
|
158
|
+
args,
|
|
159
|
+
}, context), this.#isActive, this.#assertAccess, this.#reportError);
|
|
160
|
+
const kind = this.#descriptions.get(member);
|
|
161
|
+
if (kind !== undefined)
|
|
162
|
+
slot.setDescription(kind);
|
|
163
|
+
this.#slots.set(member, slot);
|
|
164
|
+
return slot;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
class KeyedBinding {
|
|
168
|
+
#service;
|
|
169
|
+
#transport;
|
|
170
|
+
#reportError;
|
|
171
|
+
#assertAccess;
|
|
172
|
+
#onEmpty;
|
|
173
|
+
#instances;
|
|
174
|
+
#subscription;
|
|
175
|
+
#starting;
|
|
176
|
+
#closed = false;
|
|
177
|
+
#bound;
|
|
178
|
+
#revision = 0;
|
|
179
|
+
constructor(service, transport, reportError, assertAccess, onEmpty, bound) {
|
|
180
|
+
this.#service = service;
|
|
181
|
+
this.#transport = transport;
|
|
182
|
+
this.#reportError = reportError;
|
|
183
|
+
this.#assertAccess = assertAccess;
|
|
184
|
+
this.#onEmpty = onEmpty;
|
|
185
|
+
this.#bound = bound;
|
|
186
|
+
this.#instances = new InstanceDirectory({ ready: false, onError: reportError });
|
|
187
|
+
}
|
|
188
|
+
observe(handler) {
|
|
189
|
+
if (this.#closed)
|
|
190
|
+
throw new Error("Remote keyed service binding is closed");
|
|
191
|
+
let stopped = false;
|
|
192
|
+
const stop = this.#instances.observe((service, context) => {
|
|
193
|
+
const slot = new ServiceSlot(this.#service.id, true);
|
|
194
|
+
void slot.bind(service);
|
|
195
|
+
return handler(slot.view(() => {
|
|
196
|
+
this.#assertAccess();
|
|
197
|
+
if (stopped || context.abortSignal?.aborted) {
|
|
198
|
+
throw new RemoteServiceError("service_stale_instance", `Remote service ${this.#service.id} observation is closed`);
|
|
199
|
+
}
|
|
200
|
+
}), context);
|
|
201
|
+
});
|
|
202
|
+
if (this.#bound && this.#starting === undefined) {
|
|
203
|
+
const revision = this.#revision;
|
|
204
|
+
const starting = this.#start(revision);
|
|
205
|
+
this.#starting = starting;
|
|
206
|
+
void starting.catch((error) => {
|
|
207
|
+
if (!this.#closed && this.#revision === revision && this.#bound)
|
|
208
|
+
this.#reportError(toError(error));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return () => {
|
|
212
|
+
if (stopped)
|
|
213
|
+
return;
|
|
214
|
+
stopped = true;
|
|
215
|
+
stop();
|
|
216
|
+
if (this.#instances.observerCount === 0)
|
|
217
|
+
this.#onEmpty();
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async rebind(bound, context) {
|
|
221
|
+
if (this.#closed)
|
|
222
|
+
return;
|
|
223
|
+
this.#bound = bound;
|
|
224
|
+
const revision = ++this.#revision;
|
|
225
|
+
await this.#reset(context, false);
|
|
226
|
+
if (this.#closed || this.#revision !== revision || this.#bound !== bound)
|
|
227
|
+
return;
|
|
228
|
+
if (bound && this.#instances.observerCount > 0) {
|
|
229
|
+
const starting = this.#start(revision);
|
|
230
|
+
this.#starting = starting;
|
|
231
|
+
await starting;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
ready() {
|
|
235
|
+
return this.#starting ?? Promise.resolve();
|
|
236
|
+
}
|
|
237
|
+
async close(context) {
|
|
238
|
+
if (this.#closed)
|
|
239
|
+
return;
|
|
240
|
+
this.#closed = true;
|
|
241
|
+
this.#revision += 1;
|
|
242
|
+
await this.#reset(context, true);
|
|
243
|
+
this.#instances.dispose();
|
|
244
|
+
}
|
|
245
|
+
async #reset(context, waitForStarting) {
|
|
246
|
+
this.#instances.reset();
|
|
247
|
+
const starting = this.#starting;
|
|
248
|
+
this.#starting = undefined;
|
|
249
|
+
const subscription = this.#subscription;
|
|
250
|
+
this.#subscription = undefined;
|
|
251
|
+
await Promise.all([
|
|
252
|
+
waitForStarting ? starting?.catch(() => { }) : undefined,
|
|
253
|
+
subscription === undefined ? undefined : subscription.close(context),
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
async #start(revision) {
|
|
257
|
+
const subscription = await this.#transport.subscribe(this.#service.id, "keyed", (update, context) => {
|
|
258
|
+
if (this.#revision === revision)
|
|
259
|
+
this.#update(update, context);
|
|
260
|
+
}, BACKGROUND_CONTEXT);
|
|
261
|
+
if (this.#closed || !this.#bound || this.#revision !== revision) {
|
|
262
|
+
await subscription.close(BACKGROUND_CONTEXT);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.#subscription = subscription;
|
|
266
|
+
if (subscription.snapshot.mode !== "keyed" || subscription.snapshot.serviceId !== this.#service.id) {
|
|
267
|
+
throw new Error(`Remote service ${this.#service.id} returned the wrong keyed snapshot`);
|
|
268
|
+
}
|
|
269
|
+
for (const snapshot of subscription.snapshot.instances)
|
|
270
|
+
this.#spawn(snapshot, serviceDeliveryContext());
|
|
271
|
+
subscription.activate();
|
|
272
|
+
this.#instances.ready();
|
|
273
|
+
}
|
|
274
|
+
#update(update, context) {
|
|
275
|
+
if (this.#closed)
|
|
276
|
+
return;
|
|
277
|
+
try {
|
|
278
|
+
switch (update.type) {
|
|
279
|
+
case "unavailable":
|
|
280
|
+
case "replaced":
|
|
281
|
+
throw new Error("Keyed service received a singleton lifecycle update");
|
|
282
|
+
case "spawned":
|
|
283
|
+
this.#spawn(update.instance, context);
|
|
284
|
+
break;
|
|
285
|
+
case "closed": {
|
|
286
|
+
const instance = this.#instances.get(update.instance.key);
|
|
287
|
+
if (instance?.generation === update.instance.generation)
|
|
288
|
+
this.#instances.remove(instance);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "state": {
|
|
292
|
+
if (update.instance === undefined)
|
|
293
|
+
throw new Error("Keyed state update has no instance address");
|
|
294
|
+
const instance = this.#instances.get(update.instance.key);
|
|
295
|
+
if (instance?.generation !== update.instance.generation)
|
|
296
|
+
return;
|
|
297
|
+
instance.facade.update(update.member, update.sequence, update.ops, context);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
this.#reportError(toError(error));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
#spawn(snapshot, context) {
|
|
307
|
+
const address = snapshot.instance;
|
|
308
|
+
if (address === undefined)
|
|
309
|
+
throw new Error("Keyed service instance snapshot has no address");
|
|
310
|
+
let active = true;
|
|
311
|
+
const facade = new ServiceFacade(this.#service.id, address, this.#transport, () => active && !this.#closed, this.#assertAccess, this.#reportError);
|
|
312
|
+
facade.install(snapshot, context);
|
|
313
|
+
this.#instances.replace({
|
|
314
|
+
key: address.key,
|
|
315
|
+
generation: address.generation,
|
|
316
|
+
service: facade.proxy,
|
|
317
|
+
facade,
|
|
318
|
+
deactivate: () => {
|
|
319
|
+
active = false;
|
|
320
|
+
facade.clear();
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
export class RemoteServiceBindingImpl {
|
|
326
|
+
#transport;
|
|
327
|
+
#allowlist = new Set();
|
|
328
|
+
#reportError;
|
|
329
|
+
#modes = new Map();
|
|
330
|
+
#assertAccess;
|
|
331
|
+
#singletons = new Map();
|
|
332
|
+
#keyed = new Map();
|
|
333
|
+
#bound;
|
|
334
|
+
#readinessRevision = 0;
|
|
335
|
+
#bindingTransition = Promise.resolve();
|
|
336
|
+
#disposed = false;
|
|
337
|
+
constructor(options) {
|
|
338
|
+
this.#transport = options.transport;
|
|
339
|
+
const ids = options.services.map(({ id }) => id);
|
|
340
|
+
if (new Set(ids).size !== ids.length)
|
|
341
|
+
throw new TypeError("Remote service binding has duplicate service IDs");
|
|
342
|
+
for (const id of ids)
|
|
343
|
+
this.#allowlist.add(id);
|
|
344
|
+
this.#reportError = options.onError ?? (() => { });
|
|
345
|
+
this.#assertAccess = options.assertAccess ?? (() => { });
|
|
346
|
+
this.#bound = options.bound ?? true;
|
|
347
|
+
}
|
|
348
|
+
use(service) {
|
|
349
|
+
this.#assertRemotable(service);
|
|
350
|
+
this.#assertAvailable(service.id, "singleton");
|
|
351
|
+
let binding = this.#singletons.get(service.id);
|
|
352
|
+
if (binding !== undefined)
|
|
353
|
+
return binding.facade.proxy;
|
|
354
|
+
binding = { facade: undefined, active: true, revision: 0 };
|
|
355
|
+
binding.facade = new ServiceFacade(service.id, undefined, this.#transport, () => binding.active && !this.#disposed && this.#bound, () => this.#assertHandleAccess(), this.#reportError);
|
|
356
|
+
this.#singletons.set(service.id, binding);
|
|
357
|
+
this.#readinessRevision += 1;
|
|
358
|
+
if (this.#bound) {
|
|
359
|
+
const revision = binding.revision;
|
|
360
|
+
const starting = this.#startSingleton(service.id, binding, revision);
|
|
361
|
+
binding.starting = starting;
|
|
362
|
+
void starting.catch((error) => {
|
|
363
|
+
if (binding.active && binding.revision === revision && !this.#disposed && this.#bound) {
|
|
364
|
+
this.#reportError(toError(error));
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return binding.facade.proxy;
|
|
369
|
+
}
|
|
370
|
+
observe(service, handler) {
|
|
371
|
+
this.#assertRemotable(service);
|
|
372
|
+
this.#assertAvailable(service.id, "keyed");
|
|
373
|
+
let binding = this.#keyed.get(service.id);
|
|
374
|
+
if (binding === undefined) {
|
|
375
|
+
binding = new KeyedBinding(service, this.#transport, this.#reportError, () => this.#assertHandleAccess(), () => {
|
|
376
|
+
if (this.#keyed.get(service.id) !== binding)
|
|
377
|
+
return;
|
|
378
|
+
this.#keyed.delete(service.id);
|
|
379
|
+
this.#readinessRevision += 1;
|
|
380
|
+
void binding.close(BACKGROUND_CONTEXT).catch(this.#reportError);
|
|
381
|
+
}, this.#bound);
|
|
382
|
+
this.#keyed.set(service.id, binding);
|
|
383
|
+
this.#readinessRevision += 1;
|
|
384
|
+
}
|
|
385
|
+
return binding.observe(handler);
|
|
386
|
+
}
|
|
387
|
+
async ready(context) {
|
|
388
|
+
if (this.#disposed)
|
|
389
|
+
throw new Error("Remote service binding is disposed");
|
|
390
|
+
while (true) {
|
|
391
|
+
const revision = this.#readinessRevision;
|
|
392
|
+
const starts = [
|
|
393
|
+
this.#bindingTransition,
|
|
394
|
+
...[...this.#singletons.values()].flatMap((binding) => binding.starting === undefined ? [] : [binding.starting]),
|
|
395
|
+
...[...this.#keyed.values()].map((binding) => binding.ready()),
|
|
396
|
+
];
|
|
397
|
+
await awaitWithContext(Promise.all(starts).then(() => undefined), context);
|
|
398
|
+
if (this.#disposed)
|
|
399
|
+
throw new Error("Remote service binding is disposed");
|
|
400
|
+
if (revision === this.#readinessRevision)
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async rebind(bound, context) {
|
|
405
|
+
if (this.#disposed)
|
|
406
|
+
throw new Error("Remote service binding is disposed");
|
|
407
|
+
this.#bound = bound;
|
|
408
|
+
this.#readinessRevision += 1;
|
|
409
|
+
const transitions = [];
|
|
410
|
+
for (const [serviceId, binding] of this.#singletons) {
|
|
411
|
+
binding.revision += 1;
|
|
412
|
+
binding.facade.clear();
|
|
413
|
+
const subscription = binding.subscription;
|
|
414
|
+
delete binding.subscription;
|
|
415
|
+
const revision = binding.revision;
|
|
416
|
+
const starting = (async () => {
|
|
417
|
+
await subscription?.close(context);
|
|
418
|
+
if (bound)
|
|
419
|
+
await this.#startSingleton(serviceId, binding, revision);
|
|
420
|
+
})();
|
|
421
|
+
binding.starting = starting;
|
|
422
|
+
transitions.push(starting);
|
|
423
|
+
}
|
|
424
|
+
for (const binding of this.#keyed.values())
|
|
425
|
+
transitions.push(binding.rebind(bound, context));
|
|
426
|
+
const completion = (async () => {
|
|
427
|
+
const results = await Promise.allSettled(transitions);
|
|
428
|
+
const errors = results.filter((result) => result.status === "rejected");
|
|
429
|
+
if (errors.length > 0)
|
|
430
|
+
throw new AggregateError(errors.map(({ reason }) => reason), "Failed to rebind services");
|
|
431
|
+
})();
|
|
432
|
+
this.#bindingTransition = completion;
|
|
433
|
+
await completion;
|
|
434
|
+
}
|
|
435
|
+
async dispose(context) {
|
|
436
|
+
if (this.#disposed)
|
|
437
|
+
return;
|
|
438
|
+
this.#disposed = true;
|
|
439
|
+
const closes = [];
|
|
440
|
+
for (const binding of this.#singletons.values()) {
|
|
441
|
+
binding.active = false;
|
|
442
|
+
binding.facade.clear();
|
|
443
|
+
if (binding.starting)
|
|
444
|
+
closes.push(binding.starting.catch(() => { }));
|
|
445
|
+
if (binding.subscription)
|
|
446
|
+
closes.push(Promise.resolve(binding.subscription.close(context)));
|
|
447
|
+
}
|
|
448
|
+
for (const binding of this.#keyed.values())
|
|
449
|
+
closes.push(binding.close(context));
|
|
450
|
+
this.#singletons.clear();
|
|
451
|
+
this.#keyed.clear();
|
|
452
|
+
const results = await Promise.allSettled(closes);
|
|
453
|
+
const errors = results.filter((result) => result.status === "rejected");
|
|
454
|
+
if (errors.length > 0)
|
|
455
|
+
throw new AggregateError(errors.map(({ reason }) => reason), "Failed to dispose services");
|
|
456
|
+
}
|
|
457
|
+
async #startSingleton(serviceId, binding, revision) {
|
|
458
|
+
const subscription = await this.#transport.subscribe(serviceId, "singleton", (update, context) => {
|
|
459
|
+
if (!binding.active || binding.revision !== revision)
|
|
460
|
+
return;
|
|
461
|
+
try {
|
|
462
|
+
if (update.type === "unavailable") {
|
|
463
|
+
binding.facade.clear();
|
|
464
|
+
}
|
|
465
|
+
else if (update.type === "replaced") {
|
|
466
|
+
if (update.snapshot.instance !== undefined) {
|
|
467
|
+
throw new Error("Singleton replacement has an instance address");
|
|
468
|
+
}
|
|
469
|
+
binding.facade.install(update.snapshot, context);
|
|
470
|
+
}
|
|
471
|
+
else if (update.type === "state" && update.instance === undefined) {
|
|
472
|
+
binding.facade.update(update.member, update.sequence, update.ops, context);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
this.#reportError(toError(error));
|
|
477
|
+
}
|
|
478
|
+
}, BACKGROUND_CONTEXT);
|
|
479
|
+
if (!binding.active || this.#disposed || !this.#bound || binding.revision !== revision) {
|
|
480
|
+
await subscription.close(BACKGROUND_CONTEXT);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
binding.subscription = subscription;
|
|
484
|
+
const snapshot = subscription.snapshot;
|
|
485
|
+
if (snapshot.mode !== "singleton" || snapshot.serviceId !== serviceId || snapshot.instances.length !== 1) {
|
|
486
|
+
throw new Error(`Remote service ${serviceId} returned an invalid singleton snapshot`);
|
|
487
|
+
}
|
|
488
|
+
binding.facade.install(snapshot.instances[0], serviceDeliveryContext());
|
|
489
|
+
subscription.activate();
|
|
490
|
+
}
|
|
491
|
+
#assertHandleAccess() {
|
|
492
|
+
if (this.#disposed)
|
|
493
|
+
throw new Error("Remote service binding is disposed");
|
|
494
|
+
this.#assertAccess();
|
|
495
|
+
}
|
|
496
|
+
#assertRemotable(service) {
|
|
497
|
+
if (service.local)
|
|
498
|
+
throw new RemoteServiceError("service_not_allowed", `Service ${service.id} is process-local`);
|
|
499
|
+
}
|
|
500
|
+
#assertAvailable(serviceId, mode) {
|
|
501
|
+
if (this.#disposed)
|
|
502
|
+
throw new Error("Remote service binding is disposed");
|
|
503
|
+
if (!this.#allowlist.has(serviceId)) {
|
|
504
|
+
throw new RemoteServiceError("service_not_allowed", `Remote service ${serviceId} is not allowlisted`);
|
|
505
|
+
}
|
|
506
|
+
const existing = this.#modes.get(serviceId);
|
|
507
|
+
if (existing !== undefined && existing !== mode) {
|
|
508
|
+
throw new RemoteServiceError("service_mode_mismatch", `Remote service ${serviceId} is already used as ${existing}`);
|
|
509
|
+
}
|
|
510
|
+
this.#modes.set(serviceId, mode);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function validateMembers(members) {
|
|
514
|
+
const result = new Map();
|
|
515
|
+
for (const member of members) {
|
|
516
|
+
if (member.name.length === 0 || result.has(member.name)) {
|
|
517
|
+
throw new Error("Remote service has invalid member descriptions");
|
|
518
|
+
}
|
|
519
|
+
result.set(member.name, member);
|
|
520
|
+
}
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
function sameAddress(left, right) {
|
|
524
|
+
if (left === undefined || right === undefined)
|
|
525
|
+
return left === right;
|
|
526
|
+
return left.key === right.key && left.generation === right.generation;
|
|
527
|
+
}
|
|
528
|
+
function isContext(value) {
|
|
529
|
+
if (typeof value !== "object" || value === null)
|
|
530
|
+
return false;
|
|
531
|
+
const candidate = value;
|
|
532
|
+
return typeof candidate.value === "function" && typeof candidate.toString === "function";
|
|
533
|
+
}
|
|
534
|
+
function toError(error) {
|
|
535
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
536
|
+
}
|
|
537
|
+
//# sourceMappingURL=consumer.js.map
|