/// import { Observable, Subject } from 'rxjs'; import { v4 as uuid } from 'uuid'; import { RPCChannel, SocketChannel } from './channel'; import { DurableSocket } from './durable-socket'; import { inlineRemotable } from './inline-remotable'; import { AnyConstructor, Constructor, getRpcDescription, getRpcDiscoverable, getRpcEvents, getRpcIntrospectable, getRpcMethods, getRpcServiceName, getRpcType, OBJECT_ID, REFERENCE_ID } from './internal'; import { Message } from './message'; import { Method } from './method'; import { Proxied, RemoteSubscription } from './proxied'; import { Remotable } from './remotable'; import { isRemoteRef, RemoteRef } from './remote-ref'; import { isRequest, Request } from './request'; import { isResponse, Response } from './response'; import { RPCProxy } from './rpc-proxy'; import { Name } from './name'; import { RPCConsoleLogger, RPCLogger } from './logger'; import { INTENTIONAL_ERROR, RPCError, RPCInternalError, raise } from './errors'; function isRemotable(obj: any): boolean { return obj && typeof obj === 'object' && (getRpcType(obj.constructor) === 'remotable' || obj instanceof RPCProxy); } export interface InFlightRequest { /** * It is important that we hold the request, because we must not garbage collect any objects referenced by * the request in the mean time. Normally this isn't possible because there's a hard reference within our * localObjectMap, but if a proxy for this object on the remote side is garbage collected in the mean time, * we may receive a finalizeProxy() request which will cause us to remove it. Holding the request will ensure * that it's not possible for the included remotables to be garbage collected as long as the request is in flight. */ request: Request; returnValue?: any; error?: any; responseHandler: (response: any) => void; } export type ServiceFactory = (session: RPCSession) => T; interface RegisteredService { factory: ServiceFactory; description: string; discoverable: boolean; introspectable: boolean; methods: IntrospectedMethod[]; events: IntrospectedEvent[]; } export interface IntrospectedEvent { name: string; simpleType?: SimpleIntrospectedType; description?: string; //type?: IntrospectedType; } export interface DiscoveredService { name: string; discoverable: boolean; introspectable: boolean; description: string; } export interface IntrospectedMethod { name: string; parameters?: IntrospectedParameter[]; description?: string; simpleReturnType?: SimpleIntrospectedType; //returnType?: IntrospectedType; } // export interface IntrospectedType { // // TODO // } export type SimpleIntrospectedType = 'string' | 'number' | 'bigint' | 'boolean' | 'object' | 'array' | 'void' | 'undefined' | 'null' | 'unknown'; export interface IntrospectedParameter { name?: string; simpleType?: SimpleIntrospectedType; //type?: IntrospectedType; description?: string; } export interface IntrospectedService extends DiscoveredService { methods: IntrospectedMethod[]; events: IntrospectedEvent[]; } /** * Handles message passing, dispatch, resource management and other concerns for Conduit RPC sessions. Creating a * Session with a given communication channel will enable full remote procedure call functionality on that channel * without any further machination required. */ @Name(`org.webrpc.session`) @Remotable() export class RPCSession { constructor(readonly channel: RPCChannel) { this.registerBuiltinErrors(); this._remote = & { metadata: Record }> RPCProxy.create(this, getRpcServiceName(RPCSession), ''); this.registerService(RPCSession, () => this); this.registerLocalObject(this, getRpcServiceName(RPCSession)); channel.received.subscribe(data => { let message: Message; try { message = this.decodeMessage(data); } catch (e) { this.emitFatalError(new Error(`Failed to decode message. The channel will be closed. Error: ${e.stack || e}`, { cause: e })); channel.close?.(); return; } this.onReceiveMessage(message, data); }); channel.stateLost?.subscribe(() => { Array.from(this._requestMap.values()).forEach(req => { req.error = new Error(`The channel state was lost.`) }); }); } /** * Whether discovery is allowed on this session. Set to false to globally disable discovery. Note that each * service can also opt out of discovery by using the `@Discovery(false)` decorator. */ enableDiscovery = true; /** * Whether introspection is allowed on this session. Set to false to globally disable introspection. Note that each * service can also opt out of introspection by using the `@Introspectable(false)` decorator. */ enableIntrospection = true; /** * When safe exceptions mode is enabled, Conduit will only allow exception information to be sent to the client if * the exception was thrown via the raise() function provided by the @/conduit package. Other exceptions will get * turned into RPCInternalError. */ safeExceptionsMode = true; /** * When true, stack traces are removed from errors before sending them over the wire. */ maskStackTraces = true; /** * When true, the client stack trace is added to the end of deserialized errors before they are thrown. */ addCallerStackTraces = true; /** * Cause the `fatalErrors` observable to emit the specified error. * @param error */ protected emitFatalError(error: Error) { this.logger.log(String(error.stack || error), { severity: 'error' }); this._fatalErrors.next(error); } private _fatalErrors = new Subject(); /** * Receive notifications of fatal errors which cause the session to be ended. */ readonly fatalErrors = this._fatalErrors.asObservable(); /** * Responsible for logging messages out. Default implementation is RPCConsoleLogger, which just uses console.* */ logger: RPCLogger = new RPCConsoleLogger(); /** * Used by lock() and call() to allow delaying requests until some operation is complete. */ private waitChain = Promise.resolve(); /** * Delay further requests on this session until the promise returned by the given function returns. * The callback will not execute until previous locks have been completed. * - When Zone.js is available, RPC calls made within the execution context of the callback will be automatically * *not* delayed by this lock or any others. * - If Zone.js is not available, it is important that you use ignoreLocks() to perform RPC calls when done from an * async completion handler. You do not need to use ignoreLocks() if your callback is synchronous. * * Returns a promise which resolves after all previous locks (and the one created with the given callback) * have been completed */ async lock(callback: () => T): Promise { await this.waitChain; let returnValue: T; if (typeof Zone !== 'undefined') { returnValue = this.ignoreLocksAsync(() => callback()); } else { returnValue = this.ignoreLocks(() => callback()); } this.waitChain = this.waitChain.then(() => Promise.resolve(returnValue).then(() => {})); return await returnValue; } private _ignoreLocksSync = false; /** * Ignore the outstanding locks during the (synchronous) execution of the given callback. IMPORTANT: This property * does not extend to asynchronous operations performed by this function. If you need that, you need to load Zone.js * and use ignoreLocksAsync(). * @param callback */ ignoreLocks(callback: () => T): T { this._ignoreLocksSync = true; try { return callback(); } finally { this._ignoreLocksSync = false; } } /** * Ignore the outstanding locks during the (asynchronous) execution of the given callback. This function requires * Zone.js, if you do not have Zone.js loaded, you must instead use ignoreLocks() at the synchronous moment that you * start an RPC call. * * @param callback */ ignoreLocksAsync(callback: () => T): T { if (typeof Zone === 'undefined') throw new Error(`Cannot use ignoreLocksAsync() without Zone.js loaded`); return Zone.current.fork({ name: `RPCSession.lock() zone`, properties: { 'conduit:skipRPCLocks': true } }).run(() => callback()); } /** * Connect via WebSocket to the given URL and create a new RPCSession using * the socket as the underlying channel. * @param url */ static async connect(url: string): Promise { return new RPCSession((await new DurableSocket(url).waitUntilReady()).asChannel()); } /** * NOTE: The Omit is here to avoid an infinite type recursion-- even though Proxied filters out all * non-async-function properties, it still causes TS to recur. Since we don't need the remote, and the remote * isn't available on the proxy anyway, we can work around the issue by omitting the recursion source. */ private _remote: Proxied> & { metadata: Record }; /** * Retrieve the remote RPCSession for performing direct calls to it over Conduit. */ get remote() { return this._remote; } /** * Retrieve a proxy for a remote service according to the given service identity. * @param serviceIdentity A class which is annotated with `@conduit.Name()` * @throws when the remote cannot provide the given service */ async getRemoteService(serviceClass: AnyConstructor): Promise> /** * Retrieve a proxy for a remote service according to the given service identity * @param serviceIdentity The name of the service to retrieve. * @throws when the remote cannot provide the given service */ async getRemoteService(serviceIdentity: string): Promise> async getRemoteService(serviceIdentityOrClass: string | Function): Promise> { if (typeof serviceIdentityOrClass === 'function') return this.getRemoteService(getRpcServiceName(serviceIdentityOrClass)); let serviceIdentity: string = serviceIdentityOrClass; this.debugLog(`Finding remote service named '${serviceIdentity}...'`); return this.remote.getLocalService(serviceIdentity); } private rawSend(message: Message) { this.channel.send(this.encodeMessage(message)); } private encodeMessage(message: Message) { let postEncoded = JSON.stringify(message, (_, v) => isRemotable(v) ? this.remoteRef(v) : v); return postEncoded; } private decodeMessage(text: string) { this.debugLog(`Decoding: ${text}`); let postDecoded = JSON.parse(text, (_, v) => isRemoteRef(v) ? this.getObjectByRemoteRef(v) : v); this.debugLog(`Decoded:`); this.debugLogObject(postDecoded); return postDecoded; } private _requestMap = new Map(); tag: string; /** * Metadata to share with the remote side. This information is sent along whenever a new request occurs. Access * metadata sent by the remote using `remote.metadata` */ metadata: Record = {}; async call(receiver: any, method: string, parameters: any[], metadata: Record = {}): Promise { if (!receiver) throw new Error(`Must provide a receiver object for remote call`); if (!(receiver instanceof RPCProxy)) throw new Error(`Cannot RPC to local object of type '${receiver.constructor.name}'`); let clientStackTrace: string; if (this.addCallerStackTraces) { let stackTraceLimit: number; if ('stackTraceLimit' in Error) { stackTraceLimit = Error.stackTraceLimit; Error.stackTraceLimit += 10; } try { let stackTrace = new Error().stack.split(/\r?\n/).slice(1); let index = stackTrace.findIndex(t => t.includes(' RPCSession.call ')); if (index >= 0) { stackTrace = stackTrace.slice(index + 1); } clientStackTrace = [ ` JSON.stringify(x)).join(', ')})>`, ...stackTrace ].join("\n"); } finally { if ('stackTraceLimit' in Error) { Error.stackTraceLimit = stackTraceLimit; } } } let ignoreLocks = this._ignoreLocksSync; if (!ignoreLocks && typeof Zone !== 'undefined') { ignoreLocks = Zone.current.get('conduit:skipRPCLocks'); } if (!ignoreLocks) await this.waitChain; this.debugLog(`Call: [${receiver.constructor.name}/${receiver[OBJECT_ID]}].${method}()`); let rpcRequest = { type: 'request', id: uuid(), receiver, method, parameters, metadata: { ...metadata, 'rpc:session:metadata': this.metadata } }; this.debugLog(` - Before encoding:`); this.debugLogObject(rpcRequest); return new Promise((resolve, reject) => { this._requestMap.set(rpcRequest.id, { // Save the *original* request that includes local references (not converted to remote references yet) request: rpcRequest, responseHandler: (response: Response) => { if (response.error) { let error = this.deserializeError(response.error); if (clientStackTrace) error.stack = `${error.stack}\n-- Caller stack trace ---------------\n${clientStackTrace}`; reject(error); return; } resolve(response.value); } }); this.rawSend(rpcRequest); }); } private errorTypes = new Map any>(); private registerBuiltinErrors() { this.registerErrorType(RPCInternalError); this.registerBuiltinErrorType(Error); this.registerBuiltinErrorType(EvalError); this.registerBuiltinErrorType(RangeError); this.registerBuiltinErrorType(ReferenceError); this.registerBuiltinErrorType(SyntaxError); this.registerBuiltinErrorType(TypeError); this.registerBuiltinErrorType(URIError); this.registerErrorType(AggregateError, (error: { message: string, errors: unknown }) => { let errors: Error[] = []; if (error.errors && Array.isArray(error.errors)) errors = error.errors.map(x => this.deserializeError(x)); return Object.assign( new AggregateError(errors, error.message), error, { [Symbol.for('nodejs.util.inspect.custom')]() { // TODO: This isn't exactly what Node.js does. A default AggregateError shows `stack` and then // attaches the inspect breakdown on the end of that. I'm not exactly sure how it's done, it may // be an entirely custom inspect implementation. Technically we're losing the sub-error stack // traces with this implementation. return this.stack; } } ); }); } /** * Registers a builtin error class. Usually this is not required, as Conduit registers all the standard builtin * types, but if your Javascript engine supports other nonstandard types, or if there are new types Conduit doesn't * handle, you can use this to get them registered. * * This is a convenience method that does the right thing (TM) compared to the options available on * registerErrorType(). In particular it ensures that the resulting error instances have the correct inspection * behavior in Node.js. * * @param type */ public registerBuiltinErrorType(type: Constructor) { this.registerErrorType(type, error => { return Object.assign(new type(error.message), error, { [Symbol.for('nodejs.util.inspect.custom')]() { return this.stack; } }) }); } /** * Register an error type so that errors coming over the wire can be reified into the types you are expecting. * Note that builtin errors (such as TypeError, ReferenceError etc) are already registered for you. * @param type The class constructor. May optionally support a serialize() method for constructing instances * @param factory A factory function for creating instances of this class. If unspecified, the static serialize() method * is used. If no serialize() method is available, the constructor itself is used, passing the message as * the only parameter. After construction, the rest of the properties of the raw error object are assigned * to the resulting instance. */ registerErrorType(type: Constructor & { deserialize?: (error: any) => T }, factory?: (error: any) => T) { if ('deserialize' in type) { factory ??= error => type.deserialize(error); } else { factory ??= error => Object.assign(new type(error.message), error); } this.errorTypes.set(type.name, factory); } /** * Prepare an error for being thrown after being received over the wire. * @param error * @returns */ protected deserializeError(error: { $constructorName: string; name: string; message: string; stack: string; }) { if (!error.name) return error; let errorType = this.errorTypes.get(error.$constructorName) ?? this.errorTypes.get(error.name); return errorType?.(error) ?? new RPCError(error); } /** * Prepare an error for going over the wire. Doing this properly is complicated. * @param error * @returns */ protected serializeError(error: any) { if (!error.toJSON) { if (error instanceof AggregateError) { return { name: error.name, message: error.message, stack: error.stack, errors: error.errors.map(e => this.serializeError(e)), ...error }; } else if (error instanceof Error) { return { name: error.name, message: error.message, stack: error.stack, $constructorName: error.constructor.name, ...error }; } } return error; } protected async performCall(request: Request): Promise { if (request.metadata?.['rpc:session:metadata']) { this._remote.metadata = request.metadata['rpc:session:metadata']; } if (typeof Zone !== 'undefined') { return Zone.current.fork({ name: 'RPCSessionZone', properties: { 'rpc:session': this, 'rpc:request': request } }).run(() => request.receiver[request.method](...request.parameters)); } else { return request.receiver[request.method](...request.parameters); } } /** * Retrieve the RPCSession that is being served by the current remote method call. * This is only available when Zone.js is loaded. * @returns */ static current(): RPCSession { if (typeof Zone !== 'undefined') { return Zone.current.get('rpc:session'); } } static currentRequest() { if (typeof Zone !== 'undefined') { return Zone.current.get('rpc:request'); } } private async onReceiveMessage(message: Message, rawData: string) { if (isRequest(message)) { if (!message.receiver) { let rawMessage = JSON.parse(rawData); if (rawMessage.receiver) { this.logger.log(`Received invalid request: No such receiver ${JSON.stringify(rawMessage.receive)}!`, { severity: 'error' }); this.logger.log(`Message was: ${JSON.stringify(rawData, undefined, 2)}`, { severity: 'error' }); this.rawSend({ type: 'response', id: message.id, error: { code: 'invalid-call', reason: 'no-such-receiver', message: `No such receiver ${JSON.stringify(rawMessage.receive)}` } }); } else { this.logger.log(`Received invalid request: No receiver specified!`, { severity: 'error' }); this.logger.log(`Message was: ${JSON.stringify(rawData, undefined, 2)}`, { severity: 'error' }); this.rawSend({ type: 'response', id: message.id, error: { code: 'invalid-call', reason: 'no-receiver-specified', message: `No receiver specified` } }); } return; } // The ordering is important here. We *first* check if the property is a method, which would // allow custom proxies to materialize the rpc:type metadata on the fly. let isFunction = typeof message.receiver[message.method] === 'function'; let rpcType = getRpcType(message.receiver, message.method); let allowAll = Reflect.getMetadata('rpc:allow-all-calls', message.receiver); if (isFunction && (['call', 'any'].includes(rpcType) || allowAll === true)) { let value; let error; try { value = await this.performCall(message); } catch (e) { if (this.safeExceptionsMode) { if (!e[INTENTIONAL_ERROR]) { let stringRep = e.stack; if (!stringRep) { stringRep = e.toString(); if (stringRep === '[object Object]') stringRep = JSON.stringify(stringRep); } this.logger.log(`Error during ${message.receiver.constructor.name}#${message.method}(): ${e.stack ?? stringRep}`, { severity: 'error' }); e = new RPCInternalError(); } } error = this.serializeError(e); if (this.maskStackTraces && e instanceof Error && error.stack) { error.stack = `${error.name}${error.message ? `: ${error.message}` : ``}`; } } this.rawSend({ type: 'response', id: message.id, value, error }); return; } else { this.debugLog(`Failed to locate method '${message.method}' on receiver of type ${message.receiver.constructor.name} with ID ${message.receiver[OBJECT_ID]}`); this.debugLogObject(message.receiver); this.rawSend({ type: 'response', id: message.id, error: { code: 'invalid-call', message: `No such method '${message.method}'` } }); } return; } if (isResponse(message)) { let inFlightRequest = this._requestMap.get(message.id); if (!inFlightRequest) { this.logger.log(`Received response to unknown request '${message.id}'`, { severity: 'error' }); return; } this.debugLog(`Handling response for request ${message.id}...`); this._requestMap.delete(message.id); inFlightRequest.responseHandler(message); this.onRequestCompleted(inFlightRequest); return; } if (message.type === 'ping') { this.rawSend({ type: 'pong' }); return; } this.logger.log(`Unknown message type from server '${message.type}'`, { severity: 'error' }); } /** * Returns true if there are no outstanding requests or remotely held references. */ get idle() { return this.pendingRequestCount === 0 && this.remoteReferenceCount === 0; } /** * The number of in-flight requests */ get pendingRequestCount() { return this._requestMap.size; } /** * The number of remotely held references */ get remoteReferenceCount() { return this.remoteRefRegistry.size; } private _becameIdle = new Subject(); private _becameIdle$ = this._becameIdle.asObservable(); /** * Fired when the session has become idle (no pending requests or remote references). */ get becameIdle() { return this._becameIdle$; } /** * Called when a request is finished processing. * @param request */ onRequestCompleted(request: InFlightRequest) { if (this.idle) this._becameIdle.next(); } /** * Close the related channel, if it supports such an operation. */ close() { this.channel.close?.(); } private serviceRegistry = new Map(); /** * Discover the services available on the remote side. * @returns */ async discoverServices(): Promise { return await this.remote.getDiscoverableServices(); } /** * Get the list of services that are discoverable on the local side. * @returns */ @Method() async getDiscoverableServices(): Promise { if (!this.enableDiscovery) return []; return Array.from(this.serviceRegistry.entries()) .filter(([name, service]) => service.discoverable) .map(([name, service]) => ({ name, description: service.description, discoverable: service.discoverable, introspectable: service.introspectable })) ; } /** * Introspect the given remote service, if possible. * @param name The name of the service * @returns */ async introspectService(klass: Function): Promise; async introspectService(name: string): Promise; async introspectService(service: Function | string): Promise { if (typeof service === 'function') service = getRpcServiceName(service); return await this.remote.getServiceIntrospection(service); } /** * Return introspection information for the given local service. * @throws when the given service does not exist or is not introspectable. * @param name The name of the service * @returns */ @Method() async getServiceIntrospection(name: string): Promise { let notIntrospectableError = new Error(`Service does not exist or is not introspectable`); if (!this.enableIntrospection) raise(notIntrospectableError); let service = this.serviceRegistry.get(name); if (!service?.introspectable) raise(notIntrospectableError); return { name, description: service.description, discoverable: service.discoverable, introspectable: service.introspectable, methods: service.methods, events: service.events } } /** * This map tracks individual objects which we've exported via RPC via object ID. */ private localObjectRegistry = new Map>(); /** * This map tracks individual *references* sent over the wire. Each time an object is sent over the wire, * a new hard reference is created for it on the sender side. Those references must be cleaned up by the remote side * using finalizeProxy. Think of each entry in this array as a distinct RPCProxy created on the remote side. * The keys here are `.`. */ private remoteRefRegistry = new Map(); /** * Tracks the known RPCProxy objects allocated on this side of the connection for objects that exist on the remote * side. */ private proxyRegistry = new Map>(); /** * Used to track the lifetimes of remote object proxies for the purpose of releasing the corresponding remote object * (once all references have been finalized). */ private proxyFinalizer = new FinalizationRegistry((id: string) => { this.proxyRegistry.delete(id); setTimeout(() => { if (!this.proxyRegistry.has(id)) this.remote.finalizeRef(id); }, this.finalizationDelay); }); /** * Determine how many local references are held to the remote object identified by `id`. * @param id * @returns */ countReferencesForObject(id: string) { let count = Array.from(this.remoteRefRegistry.keys()).filter(x => x.startsWith(`${id}.`)).length this.debugLog(`Counted ${count} references to ${id}. Reference list:`); this.debugLogObject(Array.from(this.remoteRefRegistry.keys())); return count; } /** * How long to wait after an RPCProxy is finalized before notifying the other side * about it. If a new RPCProxy is created before the finalization delay timeout, then * the remote finalization will be cancelled. This helps to avoid a situation where the * old local proxy can go out of scope and be collected at the same time that a new request * is coming in which will revive it (via a new proxy). */ finalizationDelay = 1000; /** * Register the given proxy in the proxy and finalizer registries. This is required to ensure * we can identify the proxy by ID later, and that we are properly tracking when the finalization * of the given object occurs, so we can notify the remote side. * @param object */ private registerProxy(object: RPCProxy) { this.proxyRegistry.set(object[OBJECT_ID], new WeakRef(object)); this.proxyFinalizer.register(object, `${object[OBJECT_ID]}.${object[REFERENCE_ID]}`); } /** * Register a local object with the given ID (or one will be generated). This is required before * sending references to the local object to the remote side. * @param object * @param id */ private registerLocalObject(object: any, id?: string) { id ??= object[OBJECT_ID] ?? uuid(); object[OBJECT_ID] = id; this.localObjectRegistry.set(id, new WeakRef(object)); this.debugLog(`Registered local object with ID ${id}`); } /** * Returns a RemoteRef for the given object. The object can be a Remotable object * or an RPCProxy object (representing an object remoted from the other side). * - If the object is local and remotable, a new reference will be created for the object, * which MUST be freed later (usually by the remote side). A new reference is *always* * created in this case. * - If the object is a remote proxy, we make sure we have registered the proxy, and return * an unallocated reference to the proxy. This is useful for the remote side to identify * and reassociate the objects it has sent us, when we send that object back to them. * @param object * @returns */ remoteRef(object: any): RemoteRef { this.debugLog(`Creating remote ref for object ${object[OBJECT_ID]}.`); this.debugLog(`Determining if this is local (type ${object.constructor.name}):`); this.debugLogObject(object); if (object instanceof RPCProxy) { this.debugLog(` - It is a proxy`); // The object is a remote proxy. if (!this.proxyRegistry.has(object[OBJECT_ID])) { this.registerProxy(object); } // Note that we have no Rid (reference ID) here, because // on the remote side it will resolve to the actual object. return { 'Rε': object[OBJECT_ID], 'S': 'R' }; // it is remote to US } else { this.debugLog(` - It is local`); if (!this.localObjectRegistry.has(object[OBJECT_ID])) this.registerLocalObject(object); // Create a new reference in our local remoteRefRegistry array. // This is a hard reference, ensuring that until the remote side says it's handled this // reference in one way or another (either by using it or by finalizing it), we keep // the local object in scope. let referenceId = uuid(); this.debugLog(`Creating reference ${object[OBJECT_ID]}.${referenceId}...`); this.remoteRefRegistry.set(`${object[OBJECT_ID]}.${referenceId}`, object); return { 'Rε': object[OBJECT_ID], 'S': 'L', Rid: referenceId }; // it is local to US } } /** * Retrieve the local object for the given RemoteRef. The RemoteRef may represent * a Remotable local object or an RPCProxy object for an object remoted from the other side. * @param ref * @returns */ getObjectByRemoteRef(ref: RemoteRef) { if (!('Rε' in ref) || !ref['Rε']) return undefined; let object: any; if (ref['S'] === 'L') { this.debugLog(`Resolving proxy ${JSON.stringify(ref)}`); // Local to the other side, AKA on this side it is remote (a proxy) let weakRef = this.proxyRegistry.get(ref['Rε']); object = weakRef?.deref(); if (object) { this.debugLog(`Discarding extra proxy for '${ref['Rε']}'`); this.remote.finalizeRef(`${ref['Rε']}.${ref.Rid}`); } else { // This must be a new object from the remote. this.debugLog(`Creating new proxy for '${ref['Rε']}'`); object = RPCProxy.create(this, ref['Rε'], ref.Rid); this.proxyRegistry.set(ref['Rε'], new WeakRef(object)); } } else if (ref['S'] === 'R') { this.debugLog(`Resolving local ${JSON.stringify(ref)}`); // Remote to the other side, AKA on this side it is local object = this.getLocalObjectById(ref['Rε']); if (object === undefined) throw new Error(`No such object with ID '${ref['Rε']}'. Did you keep a reference to a dynamic object across a connection loss?`); } else { throw new Error(`RemoteRef did not specify a side`); } this.debugLog(`Resolved referenced object to:`); this.debugLogObject(object); return object; } debugLoggingEnabled = false; private debugLog(message: string) { if (this.debugLoggingEnabled) this.logger.log(`[${this.tag}] ${message}`, { severity: 'debug' }); } private debugLogObject(obj: any) { if (this.debugLoggingEnabled) this.logger.log(JSON.stringify(obj, undefined, 2), { severity: 'debug' }); } /** * Resolve the given ID to a local object, if a local object with that ID exists. * @param id * @returns The object if it was registered and not yet garbage collected. */ getLocalObjectById(id: string) { return this.localObjectRegistry.get(id)?.deref(); } /** * Register a new service on this session. * * When the remote side requests an instance of the service, the factory is called to create the instance. The * factory is passed the Session which is trying to create it, so that an instance of the service can be localized * per session, globally, or per call. * * @param klass The class implementing the service * @param factory A factory function which can create an instance of the given service. If no factory is provided, * a default factory is created which constructs the class with default parameters (this means each * session will have a separate instance of the service class). */ registerService(klass: Constructor); registerService(klass: AnyConstructor, factory: ServiceFactory); registerService(klass: AnyConstructor, factory?: ServiceFactory) { factory ??= () => new (klass as Constructor)(); if (getRpcType(klass) !== 'remotable') throw new Error(`Class '${klass.name}' must extend Service or be marked with @Remotable() to be registered as a service`); let serviceName = getRpcServiceName(klass); let discoverable = getRpcDiscoverable(klass); let introspectable = getRpcIntrospectable(klass); if (!serviceName) throw new Error(`Class '${klass.name}' must be marked with @Name()`); if (typeof serviceName !== 'string') throw new Error(`Service name must be a string`); this.debugLog(`Registering service with ID ${serviceName}...`); if (this.serviceRegistry.has(serviceName)) { throw new Error( `Cannot register instance of '${klass.name}' with service name '${serviceName}' ` + `as an instance of '${this.serviceRegistry.get(serviceName).constructor.name}' is already ` + `registered with that name.` ); } this.serviceRegistry.set(serviceName, { factory, description: getRpcDescription(klass), discoverable, introspectable, methods: getRpcMethods(klass), events: getRpcEvents(klass) }); this.debugLog(`Registered service with ID ${serviceName}`); } /** * Obtain an instance of the given service by it's identity. If the service has already been constructed, the * existing instance is used. Otherwise, the factory associated with the service registration will be called, * the new instance will be registered, and then returned. */ async getLocalService(identity: string): Promise; /** * Obtain an instance of the given service by it's constructor. If the service has already been constructed, the * existing instance is used. Otherwise, the factory associated with the service registration will be called, * the new instance will be registered, and then returned. */ async getLocalService(serviceClass: AnyConstructor): Promise; @Method() async getLocalService(serviceIdentityOrClass: string | AnyConstructor): Promise { const identity = typeof serviceIdentityOrClass === 'function' ? getRpcServiceName(serviceIdentityOrClass) : serviceIdentityOrClass; this.debugLog(`Finding local service named '${identity}'...`); if (!this.serviceRegistry.has(identity)) { this.debugLog(`No service registered with ID '${identity}'`); return null; } if (this.localObjectRegistry.has(identity)) { this.debugLog(`getLocalService(): Returning an existing service object for ${identity}...`); return this.localObjectRegistry.get(identity).deref(); } this.debugLog(`getLocalService(): Creating a new service object for ${identity}...`); let registeredService = this.serviceRegistry.get(identity); let serviceObject = registeredService.factory(this); this.registerLocalObject(serviceObject, identity); return serviceObject; } /** * Get the Conduit ID of the given object, if one has been assigned. * @param object Any object- can be a local object or a remote proxy object. * @returns */ getObjectId(object) { return object[OBJECT_ID]; } getReferenceId(object) { if (!(object instanceof RPCProxy)) throw new Error(`Reference IDs are only valid on RPCProxy objects`); return `${object[OBJECT_ID]}.${object[REFERENCE_ID]}`; } /** * Returns true if a local object with the given ID is (1) registered and (2) not garbage collected. * @param id * @returns */ isLocalObjectPresent(id: string) { this.debugLog(`isLocalObjectPresent(): Checking for ${id}...`); let weakRef = this.localObjectRegistry.get(id); if (weakRef) this.debugLog(`isLocalObjectPresent(): Weak ref is present`); else this.debugLog(`isLocalObjectPresent(): Weak ref is NOT present`); this.debugLog(`isLocalObjectPresent(): Value is ${!!weakRef.deref() ? 'NOT present' : 'present'}`); return !!weakRef.deref(); } /** * Called by the remote when a proxy has been garbage collected. * @param id */ @Method() async finalizeRef(refID: string) { if (!this.remoteRefRegistry.has(refID)) { this.logger.log(`[conduit.Session] Attempt to finalize reference '${refID}', but it is not known! This is a bug.`, { severity: 'warning' }); } this.debugLog(`Deleting reference '${refID}'`); this.remoteRefRegistry.delete(refID); if (this.idle) this._becameIdle.next(); } /** * Subscribe to an event (named `eventName`) on the given object (`eventSource`). This method is typically called * over Conduit by the remote side. It is not intended to be used on a local (non-proxied) instance of RPCSession. * * Constraints: * - The given `eventSource` must be remote from the caller's perspective (local from the perspective of the implementation). * - The given `eventReceiver` must be local from the caller's perspective (remote from the perspective of the implementation). * * The `eventSource` object should have an `eventName` property which contains an `Observable`. That observable will * be subscribed to, and the resulting emitted values will be passed to `eventReceiver` via it's `next()` method. * * @param eventSource * @param eventName * @param eventReceiver * @returns */ @Method() async subscribeToEvent(eventSource: any, eventName: string, eventReceiver: { next(value: T): void }) { if (!eventSource) throw new TypeError(`eventSource cannot be null/undefined`); if (typeof eventName !== 'string') throw new TypeError(`eventName must be a string`); if (!eventReceiver) throw new TypeError(`eventReceiver cannot be null/undefined`); if (eventSource instanceof RPCProxy) throw new Error(`[${this.tag}] eventSource must be a remote object`); // audience of this message is the remote side here if (!(eventReceiver instanceof RPCProxy)) throw new Error(`eventReceiver must be a local object`); // audience of this message is the remote side here // The ordering is important here. We *first* get the property, which would // allow custom proxies to materialize the rpc:type metadata on the fly. let observable: Observable = eventSource[eventName]; let rpcType = getRpcType(eventSource, eventName); let allowAll = Reflect.getMetadata('rpc:allow-all-calls', eventSource); if (!['event', 'any'].includes(rpcType) && allowAll !== true) throw new Error(`The '${eventName}' property is not an event.`); if (!observable.subscribe) { throw new Error(`The '${eventName}' property is not observable.`); } let subscription = await observable.subscribe(value => (eventReceiver as any).next(value)); // If the remote gets disconnected, it's important that we unsubscribe from the local observable // to ensure that we do not send next() method calls to an object which has been lost (which would // result in "No receiver specified" errors). We'll also clean up this stateLost subscription if the // observable is manually unsubscribed by the remote. if (this.channel.stateLost) { subscription.add(this.channel.stateLost.subscribe(() => { subscription.unsubscribe(); })); } return inlineRemotable({ unsubscribe: async () => { subscription.unsubscribe(); } }) } /** * This is used for testing. * @internal */ getRequestMap() { return this._requestMap; } /** * This is used for testing. * @internal */ finalizeProxy(proxy) { if (!(proxy instanceof RPCProxy)) throw new Error(`Argument must be an RPCProxy`); this.finalizeRef(`${proxy[OBJECT_ID]}.${proxy[REFERENCE_ID]}`); } }