From 8f7ae4fc2304924f903add5648604969a012b0c0 Mon Sep 17 00:00:00 2001 From: Barak Date: Thu, 23 Jan 2025 12:32:14 +0200 Subject: [PATCH 1/9] feat(package): add sideEffects flag to package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 96ce5c9a1..61da810ae 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test-fixtures/*", "test-fixtures/workspace/packages/*" ], + "sideEffects": false, "scripts": { "clean": "rimraf -g \"./packages/*/dist\" \"./examples/*/dist\" \"./test-fixtures/*/dist\" \"./test-fixtures/workspace/packages/*/dist\"", "build": "npm run build:typescript && npm run build:dashboard", From 1f66c1eb2f29d0058d6707afc007479c9efbf75d Mon Sep 17 00:00:00 2001 From: Barak Date: Thu, 23 Jan 2025 12:32:36 +0200 Subject: [PATCH 2/9] feat(core): add error handling for non-function arguments in multiTenantMethod --- packages/core/src/com/service-config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/com/service-config.ts b/packages/core/src/com/service-config.ts index 05fd3980e..49ba9ed9e 100644 --- a/packages/core/src/com/service-config.ts +++ b/packages/core/src/com/service-config.ts @@ -1,15 +1,15 @@ import type { AnyFunction, FilterFirstArgument, ValuePromise } from './types.js'; export function multiTenantMethod(method: T) { + if (!(typeof method === 'function')) { + throw new Error('No Such function'); + } return (context: any) => { function getArgs([_first, ...rest]: Parameters) { return rest as FilterFirstArgument; } function proxyFunction(...args: ReturnType): ValuePromise> { - if (typeof method === 'function') { - return method.call(context, ...args) as ValuePromise>; - } - throw new Error('No Such function'); + return method.call(context, ...args) as ValuePromise>; } return { getArgs, From b1323977a445c493ce3b3a9540ee83d50283bcf3 Mon Sep 17 00:00:00 2001 From: Barak Date: Thu, 23 Jan 2025 12:33:09 +0200 Subject: [PATCH 3/9] feat(engine-cli): add lang attribute to html tag in esbuild-html-plugin --- packages/engine-cli/src/esbuild-html-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine-cli/src/esbuild-html-plugin.ts b/packages/engine-cli/src/esbuild-html-plugin.ts index f8b404c03..f5e073201 100644 --- a/packages/engine-cli/src/esbuild-html-plugin.ts +++ b/packages/engine-cli/src/esbuild-html-plugin.ts @@ -42,7 +42,7 @@ export function htmlPlugin({ const cssPath = meta.cssBundle ? path.basename(meta.cssBundle) : undefined; const htmlContent = deindento(` | - | + | | | | ${title} From 68711b16d1d7cd6a11af063dde02ac0cfac1a671 Mon Sep 17 00:00:00 2001 From: Barak Date: Thu, 23 Jan 2025 12:33:22 +0200 Subject: [PATCH 4/9] feat(runtime-node): remove ipc-environment.ts file --- packages/runtime-node/src/ipc-environment.ts | 40 -------------------- 1 file changed, 40 deletions(-) delete mode 100644 packages/runtime-node/src/ipc-environment.ts diff --git a/packages/runtime-node/src/ipc-environment.ts b/packages/runtime-node/src/ipc-environment.ts deleted file mode 100644 index 1d08c9bed..000000000 --- a/packages/runtime-node/src/ipc-environment.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { COM } from '@wixc3/engine-core'; -import { IPCHost } from './core-node/ipc-host.js'; -import { runNodeEnvironment } from './node-environment.js'; -import { type StartEnvironmentOptions } from './types.js'; - -export interface StartIPCEnvironmntOptions extends StartEnvironmentOptions { - parentEnvName: string; -} - -export async function runIPCEnvironment(options: StartIPCEnvironmntOptions) { - const disposeHandlers = new Set<() => unknown>(); - const host = new IPCHost(process); - disposeHandlers.add(() => host.dispose()); - const config = [ - ...(options.config ?? []), - COM.configure({ - config: { - connectedEnvironments: { - [options.parentEnvName]: { - id: options.parentEnvName, - host, - }, - }, - }, - }), - ]; - const engine = await runNodeEnvironment({ - ...options, - host, - config, - }); - disposeHandlers.add(engine.shutdown); - return { - close: async () => { - for (const disposeHandler of disposeHandlers) { - await disposeHandler(); - } - }, - }; -} From 49e935f65af97dd6ba5dccbea694035d68acfd91 Mon Sep 17 00:00:00 2001 From: Barak Date: Thu, 23 Jan 2025 12:33:34 +0200 Subject: [PATCH 5/9] feat(engine-cli): export NodeEnvManager instance from create-node-entrypoint --- .../engine-cli/src/entrypoint/create-node-entrypoint.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/engine-cli/src/entrypoint/create-node-entrypoint.ts b/packages/engine-cli/src/entrypoint/create-node-entrypoint.ts index d56e2db0e..a16255851 100644 --- a/packages/engine-cli/src/entrypoint/create-node-entrypoint.ts +++ b/packages/engine-cli/src/entrypoint/create-node-entrypoint.ts @@ -20,14 +20,8 @@ import { NodeEnvManager } from '@wixc3/engine-runtime-node'; const featureEnvironmentsMapping = ${stringify(featureEnvironmentsMapping)}; const configMapping = ${stringify(configMapping)}; const meta = { url: ${moduleType === 'esm' ? 'import.meta.url' : 'pathToFileURL(__filename).href'} }; -const manager = new NodeEnvManager(meta, featureEnvironmentsMapping, configMapping); +export const manager = new NodeEnvManager(meta, featureEnvironmentsMapping, configMapping); -manager.autoLaunch().then(({ port })=>{ - console.log(\`[ENGINE]: http server is listening on http://localhost:\${port}\`); -}).catch((e)=>{ - process.exitCode = 1; - console.error(e); -}); `.trimStart(); } From 41add1c4a177885ceb63f9adfc4d0134b69cd658 Mon Sep 17 00:00:00 2001 From: Barak Date: Thu, 23 Jan 2025 12:36:37 +0200 Subject: [PATCH 6/9] feat(runtime-node): remove ipc-environment.js export from index.ts --- packages/runtime-node/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/runtime-node/src/index.ts b/packages/runtime-node/src/index.ts index c5f74ce72..a754fccdb 100644 --- a/packages/runtime-node/src/index.ts +++ b/packages/runtime-node/src/index.ts @@ -6,7 +6,6 @@ export * from './core-node/parent-port-host.js'; export * from './core-node/ws-node-host.js'; export * from './environments.js'; export * from './import-modules.js'; -export * from './ipc-environment.js'; export * from './launch-http-server.js'; export * from './load-top-level-config.js'; export * from './node-env-manager.js'; From 3649894d488b3abf0fd95ae87d07dc80dca637ac Mon Sep 17 00:00:00 2001 From: Barak Date: Mon, 10 Feb 2025 03:48:52 +0200 Subject: [PATCH 7/9] feat(communication): add signal support to communication --- packages/core/src/com/communication.ts | 64 ++++++++++++++++++++------ packages/core/src/com/types.ts | 7 ++- packages/core/src/entities/service.ts | 7 ++- packages/core/test/node/com.spec.ts | 38 ++++++++++++++- 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/packages/core/src/com/communication.ts b/packages/core/src/com/communication.ts index cd5867030..85b19a4fd 100644 --- a/packages/core/src/com/communication.ts +++ b/packages/core/src/com/communication.ts @@ -1,4 +1,4 @@ -import { isDisposable, SetMultiMap } from '@wixc3/patterns'; +import { isDisposable, SetMultiMap, Signal } from '@wixc3/patterns'; import { deferred } from 'promise-assist'; import type { ContextualEnvironment, Environment, EnvironmentMode } from '../entities/env.js'; import { errorToJson } from '../helpers/index.js'; @@ -188,9 +188,9 @@ export class Communication { serviceComConfig: ServiceComConfig = {}, ): AsyncApi { return new Proxy(Object.create(null), { - get: (obj, method) => { + get: (runtimeCache, key) => { // let js runtime know that this is not thenable object - if (method === 'then') { + if (key === 'then') { return undefined; } @@ -199,24 +199,55 @@ export class Communication { * they used by the debugger and cause messages to be sent everywhere * this behavior made debugging very hard and can cause errors and infinite loops */ - if (Object.hasOwn(Object.prototype, method)) { - return Reflect.get(Object.prototype, method); + if (Object.hasOwn(Object.prototype, key)) { + return Reflect.get(Object.prototype, key); } - if (typeof method === 'string') { - let runtimeMethod = obj[method]; - if (!runtimeMethod) { - runtimeMethod = async (...args: unknown[]) => + if (typeof key === 'string') { + let runtimeValue = runtimeCache[key]; + if (!runtimeValue) { + runtimeValue = async (...args: unknown[]) => this.callMethod( (await instanceToken).id, api, - method, + key, args, this.rootEnvId, serviceComConfig as Record, ); - obj[method] = runtimeMethod; + //////////// Signal handling //////////// + const subSignalId = key + '.' + 'subscribe'; + const unsubSignalId = key + '.' + 'unsubscribe'; + (serviceComConfig as Record)[subSignalId] = { + emitOnly: true, + listener: true, + }; + (serviceComConfig as Record)[unsubSignalId] = { + emitOnly: true, + removeListener: subSignalId, + }; + runtimeValue.subscribe = async (fn: UnknownFunction) => { + return this.callMethod( + (await instanceToken).id, + api, + subSignalId, + [fn], + this.rootEnvId, + serviceComConfig as Record, + ); + }; + runtimeValue.unsubscribe = async (fn: UnknownFunction) => { + return this.callMethod( + (await instanceToken).id, + api, + unsubSignalId, + [fn], + this.rootEnvId, + serviceComConfig as Record, + ); + }; + runtimeCache[key] = runtimeValue; } - return runtimeMethod; + return runtimeValue; } }, }); @@ -612,9 +643,14 @@ export class Communication { private apiCall(origin: string, api: string, method: string, args: unknown[]): unknown { if (this.apisOverrides[api]?.[method]) { - return this.apisOverrides[api][method](...[origin, ...args]); + return (this.apisOverrides[api][method] as UnknownFunction)(...[origin, ...args]); + } + if (method.includes('.')) { + const [apiName, methodName] = method.split('.') as [string, 'subscribe' | 'unsubscribe']; + const signal = this.apis[api]![apiName] as Signal; + return signal[methodName](args[0] as UnknownFunction); } - return this.apis[api]![method]!(...args); + return (this.apis[api]![method] as UnknownFunction)(...args); } private unhandledMessage(message: Message): void { diff --git a/packages/core/src/com/types.ts b/packages/core/src/com/types.ts index 9c41983be..660c39141 100644 --- a/packages/core/src/com/types.ts +++ b/packages/core/src/com/types.ts @@ -1,3 +1,4 @@ +import { Signal } from '@wixc3/patterns'; import { SERVICE_CONFIG } from '../symbols.js'; import { Message } from './message-types.js'; @@ -63,7 +64,9 @@ export type AsyncApi = { ? T[P] : T[P] extends (...args: infer Args) => infer R ? (...args: Args) => Promise - : never; + : T[P] extends Signal + ? Pick + : never; } & { [K in Extract]: never; }; @@ -117,5 +120,5 @@ export interface APIService { } export interface RemoteAPIServicesMapping { - [remoteServiceId: string]: Record; + [remoteServiceId: string]: Record; } diff --git a/packages/core/src/entities/service.ts b/packages/core/src/entities/service.ts index 946d6f46f..05d208d58 100644 --- a/packages/core/src/entities/service.ts +++ b/packages/core/src/entities/service.ts @@ -66,14 +66,13 @@ export class Service< providedFrom.has(Universal.env) || hasIntersection(providedFrom, runtimeEngine.runningEnvNames); if (shouldIncludeService) { if (!providedValue) { - throw new Error( - `Service is not provided at runtime. + throw new Error(` +Service is not provided at runtime. Make sure the environment setup file exists and named correctly: [featureName].[envName].env.[ext] Service name: ${entityKey} Feature id: ${featureID} Environment: ${runtimeEngine.entryEnvironment.env} - `, - ); +`); } communication.registerAPI({ id: serviceKey }, providedValue); return providedValue; diff --git a/packages/core/test/node/com.spec.ts b/packages/core/test/node/com.spec.ts index 14d6ba1a0..42b0e2d62 100644 --- a/packages/core/test/node/com.spec.ts +++ b/packages/core/test/node/com.spec.ts @@ -13,7 +13,7 @@ import { multiTenantMethod, type Message, } from '@wixc3/engine-core'; -import { EventEmitter } from '@wixc3/patterns'; +import { EventEmitter, Signal } from '@wixc3/patterns'; import * as chai from 'chai'; import { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -56,6 +56,42 @@ describe('Communication', () => { expect(res).to.be.equal('Yoo!'); }); + it('signal support', async () => { + class EchoServiceWithSignal { + onChange = new Signal(); + echo(s: string) { + return s; + } + } + const host = new BaseHost(); + const main = new Communication(host, 'main'); + const echoService = new EchoServiceWithSignal(); + main.registerAPI({ id: 'echoService' }, echoService); + + const proxy = main.apiProxy(Promise.resolve({ id: 'main' }), { id: 'echoService' }); + const out: string[] = []; + + const handler = (e: string) => { + out.push(e); + }; + proxy.onChange.subscribe(handler); + + // this code simulate cross environment communication it cannot be synchronous + await sleep(0); + echoService.onChange.notify('test'); + ////////////////////////////////////////////////////////////////////////////// + + proxy.onChange.unsubscribe(handler); + + // this code simulate cross environment communication it cannot be synchronous + await sleep(0); + echoService.onChange.notify('test 2'); + await sleep(0); + ////////////////////////////////////////////////////////////////////////////// + + expect(out).to.eql(['test']); + }); + it('multi communication', async () => { const host = new BaseHost(); const main = new Communication(host, 'main'); From 29300fac85524888593273d8d4e98c55d8488525 Mon Sep 17 00:00:00 2001 From: Barak Igal Date: Tue, 18 Nov 2025 01:03:01 +0200 Subject: [PATCH 8/9] feat: implement ValueSignal class and update communication logic to support new signal methods --- packages/core/src/com/communication.ts | 20 ++++- packages/core/src/com/types.ts | 6 +- packages/core/src/index.ts | 1 + packages/core/src/value-signal.ts | 119 +++++++++++++++++++++++++ packages/core/test/node/com.spec.ts | 13 +-- 5 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/value-signal.ts diff --git a/packages/core/src/com/communication.ts b/packages/core/src/com/communication.ts index aaf5c53e1..4e2a2a2d7 100644 --- a/packages/core/src/com/communication.ts +++ b/packages/core/src/com/communication.ts @@ -1,4 +1,4 @@ -import { isDisposable, SetMultiMap, Signal } from '@dazl/patterns'; +import { isDisposable, SetMultiMap } from '@dazl/patterns'; import { deferred } from 'promise-assist'; import type { ContextualEnvironment, Environment, EnvironmentMode } from '../entities/env.js'; import { errorToJson } from '../helpers/index.js'; @@ -56,6 +56,7 @@ import { UnConfiguredMethodError, UnknownCallbackIdError, } from './communication-errors.js'; +import { ValueSignal } from '../value-signal.js'; export interface ConfigEnvironmentRecord extends EnvironmentRecord { registerMessageHandler?: boolean; @@ -254,6 +255,16 @@ export class Communication { serviceComConfig as Record, ); }; + runtimeValue.getValue = async () => { + return this.callMethod( + (await instanceToken).id, + api, + key + '.getValue', + [], + this.rootEnvId, + serviceComConfig as Record, + ); + }; runtimeCache[key] = runtimeValue; } return runtimeValue; @@ -674,9 +685,10 @@ export class Communication { return (this.apisOverrides[api][method] as UnknownFunction)(...[origin, ...args]); } if (method.includes('.')) { - const [apiName, methodName] = method.split('.') as [string, 'subscribe' | 'unsubscribe']; - const signal = this.apis[api]![apiName] as Signal; - return signal[methodName](args[0] as UnknownFunction); + const [apiName, methodName] = method.split('.') as [string, 'subscribe' | 'unsubscribe' | 'getValue']; + const signal = this.apis[api]![apiName] as ValueSignal; + const fnAsrgs = args as [UnknownFunction]; + return signal[methodName](...fnAsrgs); } return (this.apis[api]![method] as UnknownFunction)(...args); } diff --git a/packages/core/src/com/types.ts b/packages/core/src/com/types.ts index dcf660844..874b842a3 100644 --- a/packages/core/src/com/types.ts +++ b/packages/core/src/com/types.ts @@ -1,5 +1,5 @@ -import { Signal } from '@wixc3/patterns'; import { SERVICE_CONFIG } from '../symbols.js'; +import { RemotelyAccessibleSignalMethods, ValueSignal } from '../value-signal.js'; import { Message } from './message-types.js'; export type SerializableArguments = unknown[]; @@ -65,8 +65,8 @@ export type AsyncApi = { ? T[P] : T[P] extends (...args: infer Args) => infer R ? (...args: Args) => Promise - : T[P] extends Signal - ? Pick + : T[P] extends ValueSignal + ? Pick : never; } & { [K in Extract]: never; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bd8f382f8..e7145f2bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export * from './runtime-feature.js'; export * from './symbols.js'; export * from './types.js'; export * from './communication.feature.js'; +export * from './value-signal.js'; export * from './runtime-main.js'; export * from './runtime-configurations.js'; diff --git a/packages/core/src/value-signal.ts b/packages/core/src/value-signal.ts new file mode 100644 index 000000000..cbac74a27 --- /dev/null +++ b/packages/core/src/value-signal.ts @@ -0,0 +1,119 @@ +export type Listener = (data: T, version: number) => void; +type IS_ONCE = boolean; + +export type RemotelyAccessibleSignalMethods = 'subscribe' | 'unsubscribe' | 'getValue'; + +/** + * Signal is a simple event emitter for one type of event. + + * @example + * ```ts + * const foodArrived = new Signal(); + * + * foodArrived.subscribe(() => { + * console.log('Food arrived!'); + * }); + * + * foodArrived.setValueAndNotify(new Food('pizza')); + * ``` + * + * @example Usage in a class: + * ```ts + * class LoginService { + * public onLoginSuccess = new Signal(); + * public onLoginFailure = new Signal(); + * public onLoginStatusChange = new Signal(); + * } + * ``` + * @remarks + * Use Signals a public api for emitting events. + * Naming a signal is like naming the event the it triggers. + * If the name sounds like a property try to add a `on` prefix or `Change/Signal` suffix. + * All methods are bound to the Signal instance + * + * Notice that the Signals are public. + * We don't need to implement specific subscriptions on the class, unless we need to expose it as a remote service. + */ +export class ValueSignal { + private handlers = new Map, IS_ONCE>(); + private value: T; + private version: number = 0; + + constructor(initialValue: T, handlers?: Listener[]) { + handlers?.forEach((handler) => this.subscribe(handler)); + this.value = initialValue; + } + + /** + * Get the current value + */ + getValue = (): T => { + return this.value; + }; + + /** + * Subscribe a notification callback + * + * @param handler - Will be executed with a data arg when a notification occurs + */ + subscribe = (handler: Listener) => { + if (this.handlers.get(handler) !== true) { + this.handlers.set(handler, false); + } else { + throw new Error(`handler already exists as "once" listener`); + } + }; + + /** + * Subscribe to only the next notification + * + * @param handler - Will be executed with a data arg when a notification occurs + */ + once = (handler: Listener) => { + if (this.handlers.get(handler) !== false) { + this.handlers.set(handler, true); + } else { + throw new Error(`handler already exists as persistent listener`); + } + }; + + /** + * @returns true if a listener is subscribed + */ + has(value: Listener): boolean { + return this.handlers.has(value); + } + + /** + * Unsubscribe an existing callback + */ + unsubscribe = (handler: Listener) => { + this.handlers.delete(handler); + }; + + get size(): number { + return this.handlers.size; + } + + /** + * Set the value and notify all subscribers with the new data. + * Only notifies if the value has changed. + */ + setValueAndNotify = (data: T) => { + if (this.value === data) { + return; + } + this.version++; + this.value = data; + for (const [handler, isOnce] of this.handlers) { + handler(data, this.version); + if (isOnce) { + this.handlers.delete(handler); + } + } + }; + + clear(): void { + this.handlers.clear(); + } +} diff --git a/packages/core/test/node/com.spec.ts b/packages/core/test/node/com.spec.ts index f97c2a46c..32a596015 100644 --- a/packages/core/test/node/com.spec.ts +++ b/packages/core/test/node/com.spec.ts @@ -11,8 +11,8 @@ import { Slot, declareComEmitter, multiTenantMethod, + ValueSignal, } from '@dazl/engine-core'; -import { Signal } from '@dazl/patterns'; import * as chai from 'chai'; import { expect } from 'chai'; import chaiAsPromised from 'chai-as-promised'; @@ -57,7 +57,7 @@ describe('Communication', () => { it('signal support', async () => { class EchoServiceWithSignal { - onChange = new Signal(); + onChange = new ValueSignal(''); echo(s: string) { return s; } @@ -69,25 +69,28 @@ describe('Communication', () => { const proxy = main.apiProxy(Promise.resolve({ id: 'main' }), { id: 'echoService' }); const out: string[] = []; + const versions: number[] = []; - const handler = (e: string) => { + const handler = (e: string, version: number) => { out.push(e); + versions.push(version); }; proxy.onChange.subscribe(handler); // this code simulate cross environment communication it cannot be synchronous await sleep(0); - echoService.onChange.notify('test'); + echoService.onChange.setValueAndNotify('test'); ////////////////////////////////////////////////////////////////////////////// proxy.onChange.unsubscribe(handler); // this code simulate cross environment communication it cannot be synchronous await sleep(0); - echoService.onChange.notify('test 2'); + echoService.onChange.setValueAndNotify('test 2'); await sleep(0); ////////////////////////////////////////////////////////////////////////////// + expect(versions).to.eql([1]); expect(out).to.eql(['test']); }); From 576521499e89969cb80b171b14a011918bbbe18e Mon Sep 17 00:00:00 2001 From: Barak Igal Date: Tue, 18 Nov 2025 13:14:08 +0200 Subject: [PATCH 9/9] feat: refactor signal handling to use RemoteValue and AsyncRemoteValue types --- packages/core/src/com/communication.ts | 124 ++++++++++++++++--------- packages/core/src/com/types.ts | 8 +- packages/core/src/value-signal.ts | 97 +++---------------- packages/core/test/node/com.spec.ts | 4 +- 4 files changed, 100 insertions(+), 133 deletions(-) diff --git a/packages/core/src/com/communication.ts b/packages/core/src/com/communication.ts index 4e2a2a2d7..c7aaca0b6 100644 --- a/packages/core/src/com/communication.ts +++ b/packages/core/src/com/communication.ts @@ -56,7 +56,7 @@ import { UnConfiguredMethodError, UnknownCallbackIdError, } from './communication-errors.js'; -import { ValueSignal } from '../value-signal.js'; +import { RemoteValue } from '../value-signal.js'; export interface ConfigEnvironmentRecord extends EnvironmentRecord { registerMessageHandler?: boolean; @@ -225,47 +225,10 @@ export class Communication { serviceComConfig as Record, ); //////////// Signal handling //////////// - const subSignalId = key + '.' + 'subscribe'; - const unsubSignalId = key + '.' + 'unsubscribe'; - (serviceComConfig as Record)[subSignalId] = { - emitOnly: true, - listener: true, - }; - (serviceComConfig as Record)[unsubSignalId] = { - emitOnly: true, - removeListener: subSignalId, - }; - runtimeValue.subscribe = async (fn: UnknownFunction) => { - return this.callMethod( - (await instanceToken).id, - api, - subSignalId, - [fn], - this.rootEnvId, - serviceComConfig as Record, - ); - }; - runtimeValue.unsubscribe = async (fn: UnknownFunction) => { - return this.callMethod( - (await instanceToken).id, - api, - unsubSignalId, - [fn], - this.rootEnvId, - serviceComConfig as Record, - ); - }; - runtimeValue.getValue = async () => { - return this.callMethod( - (await instanceToken).id, - api, - key + '.getValue', - [], - this.rootEnvId, - serviceComConfig as Record, - ); - }; - runtimeCache[key] = runtimeValue; + runtimeCache[key] = Object.assign( + runtimeValue, + this.createAsyncRemoteValue(key, serviceComConfig, instanceToken, api), + ); } return runtimeValue; } @@ -273,6 +236,76 @@ export class Communication { }); } + private createAsyncRemoteValue( + key: string, + serviceComConfig: ServiceComConfig, + instanceToken: EnvironmentInstanceToken | Promise, + api: string, + ) { + const subSignalId = key + '.' + 'subscribe'; + const unsubSignalId = key + '.' + 'unsubscribe'; + const streamSignalId = key + '.' + 'stream'; + const getValueId = key + '.getValue'; + + (serviceComConfig as Record)[subSignalId] = { + emitOnly: true, + listener: true, + }; + (serviceComConfig as Record)[unsubSignalId] = { + emitOnly: true, + removeListener: subSignalId, + }; + (serviceComConfig as Record)[streamSignalId] = { + emitOnly: true, + listener: true, + }; + + const asyncRemoteValue = { + subscribe: async (...args: unknown[]) => { + return this.callMethod( + (await instanceToken).id, + api, + subSignalId, + args, + this.rootEnvId, + serviceComConfig as Record, + ); + }, + unsubscribe: async (fn: UnknownFunction) => { + return this.callMethod( + (await instanceToken).id, + api, + unsubSignalId, + [fn], + this.rootEnvId, + serviceComConfig as Record, + ); + }, + stream: async (fn: UnknownFunction) => { + return this.callMethod( + (await instanceToken).id, + api, + streamSignalId, + [fn], + this.rootEnvId, + serviceComConfig as Record, + ); + }, + getValue: async () => { + return this.callMethod( + (await instanceToken).id, + api, + getValueId, + [], + this.rootEnvId, + serviceComConfig as Record, + ); + }, + }; + + return asyncRemoteValue; + } + /** * Add local handle event listener to Target. */ @@ -685,8 +718,11 @@ export class Communication { return (this.apisOverrides[api][method] as UnknownFunction)(...[origin, ...args]); } if (method.includes('.')) { - const [apiName, methodName] = method.split('.') as [string, 'subscribe' | 'unsubscribe' | 'getValue']; - const signal = this.apis[api]![apiName] as ValueSignal; + const [apiName, methodName] = method.split('.') as [ + string, + 'subscribe' | 'unsubscribe' | 'getValue' | 'stream', + ]; + const signal = this.apis[api]![apiName] as RemoteValue; const fnAsrgs = args as [UnknownFunction]; return signal[methodName](...fnAsrgs); } diff --git a/packages/core/src/com/types.ts b/packages/core/src/com/types.ts index 874b842a3..e7146b73d 100644 --- a/packages/core/src/com/types.ts +++ b/packages/core/src/com/types.ts @@ -1,5 +1,5 @@ import { SERVICE_CONFIG } from '../symbols.js'; -import { RemotelyAccessibleSignalMethods, ValueSignal } from '../value-signal.js'; +import { AsyncRemoteValue, RemoteValue } from '../value-signal.js'; import { Message } from './message-types.js'; export type SerializableArguments = unknown[]; @@ -65,8 +65,8 @@ export type AsyncApi = { ? T[P] : T[P] extends (...args: infer Args) => infer R ? (...args: Args) => Promise - : T[P] extends ValueSignal - ? Pick + : T[P] extends RemoteValue + ? AsyncRemoteValue : never; } & { [K in Extract]: never; @@ -121,5 +121,5 @@ export interface APIService { } export interface RemoteAPIServicesMapping { - [remoteServiceId: string]: Record; + [remoteServiceId: string]: Record>; } diff --git a/packages/core/src/value-signal.ts b/packages/core/src/value-signal.ts index cbac74a27..341d19079 100644 --- a/packages/core/src/value-signal.ts +++ b/packages/core/src/value-signal.ts @@ -1,100 +1,38 @@ export type Listener = (data: T, version: number) => void; -type IS_ONCE = boolean; +export type AsyncRemoteValue = { + getValue: () => Promise; + stream: (handler: Listener) => void; + subscribe: (handler: Listener) => void; + unsubscribe: (handler: Listener) => void; +}; export type RemotelyAccessibleSignalMethods = 'subscribe' | 'unsubscribe' | 'getValue'; -/** - * Signal is a simple event emitter for one type of event. - - * @example - * ```ts - * const foodArrived = new Signal(); - * - * foodArrived.subscribe(() => { - * console.log('Food arrived!'); - * }); - * - * foodArrived.setValueAndNotify(new Food('pizza')); - * ``` - * - * @example Usage in a class: - * ```ts - * class LoginService { - * public onLoginSuccess = new Signal(); - * public onLoginFailure = new Signal(); - * public onLoginStatusChange = new Signal(); - * } - * ``` - * @remarks - * Use Signals a public api for emitting events. - * Naming a signal is like naming the event the it triggers. - * If the name sounds like a property try to add a `on` prefix or `Change/Signal` suffix. - * All methods are bound to the Signal instance - * - * Notice that the Signals are public. - * We don't need to implement specific subscriptions on the class, unless we need to expose it as a remote service. - */ -export class ValueSignal { - private handlers = new Map, IS_ONCE>(); +export class RemoteValue { + private handlers = new Set>(); private value: T; private version: number = 0; - constructor(initialValue: T, handlers?: Listener[]) { - handlers?.forEach((handler) => this.subscribe(handler)); + constructor(initialValue: T) { this.value = initialValue; } - /** - * Get the current value - */ getValue = (): T => { return this.value; }; - /** - * Subscribe a notification callback - * - * @param handler - Will be executed with a data arg when a notification occurs - */ subscribe = (handler: Listener) => { - if (this.handlers.get(handler) !== true) { - this.handlers.set(handler, false); - } else { - throw new Error(`handler already exists as "once" listener`); - } + this.handlers.add(handler); }; - - /** - * Subscribe to only the next notification - * - * @param handler - Will be executed with a data arg when a notification occurs - */ - once = (handler: Listener) => { - if (this.handlers.get(handler) !== false) { - this.handlers.set(handler, true); - } else { - throw new Error(`handler already exists as persistent listener`); - } + stream = (handler: Listener) => { + this.subscribe(handler); + handler(this.value, this.version); }; - /** - * @returns true if a listener is subscribed - */ - has(value: Listener): boolean { - return this.handlers.has(value); - } - - /** - * Unsubscribe an existing callback - */ unsubscribe = (handler: Listener) => { this.handlers.delete(handler); }; - get size(): number { - return this.handlers.size; - } - /** * Set the value and notify all subscribers with the new data. * Only notifies if the value has changed. @@ -105,15 +43,8 @@ export class ValueSignal { } this.version++; this.value = data; - for (const [handler, isOnce] of this.handlers) { + for (const handler of this.handlers) { handler(data, this.version); - if (isOnce) { - this.handlers.delete(handler); - } } }; - - clear(): void { - this.handlers.clear(); - } } diff --git a/packages/core/test/node/com.spec.ts b/packages/core/test/node/com.spec.ts index 32a596015..1b4b99cfe 100644 --- a/packages/core/test/node/com.spec.ts +++ b/packages/core/test/node/com.spec.ts @@ -11,7 +11,7 @@ import { Slot, declareComEmitter, multiTenantMethod, - ValueSignal, + RemoteValue, } from '@dazl/engine-core'; import * as chai from 'chai'; import { expect } from 'chai'; @@ -57,7 +57,7 @@ describe('Communication', () => { it('signal support', async () => { class EchoServiceWithSignal { - onChange = new ValueSignal(''); + onChange = new RemoteValue(''); echo(s: string) { return s; }