@wix/realtime 1.0.15 → 1.0.17
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/cjs/build/constants.d.ts +1 -0
- package/cjs/build/constants.js +4 -0
- package/cjs/build/index.d.ts +3 -0
- package/{build/cjs → cjs/build}/index.js +3 -1
- package/cjs/build/publisher/publisher.context.d.ts +37 -0
- package/cjs/build/publisher/publisher.context.js +40 -0
- package/cjs/build/publisher/publisher.public.d.ts +12 -0
- package/cjs/build/publisher/publisher.public.js +74 -0
- package/cjs/build/subscriber/errors.d.ts +16 -0
- package/cjs/build/subscriber/errors.js +70 -0
- package/cjs/build/subscriber/subscriber.context.d.ts +4 -0
- package/cjs/build/subscriber/subscriber.context.js +7 -0
- package/cjs/build/subscriber/subscriber.public.d.ts +21 -0
- package/cjs/build/subscriber/subscriber.public.js +234 -0
- package/cjs/build/subscriber/subscription-manager.d.ts +26 -0
- package/cjs/build/subscriber/subscription-manager.js +75 -0
- package/cjs/build/types.d.ts +1 -0
- package/cjs/build/types.js +6 -0
- package/cjs/package.json +3 -0
- package/dist/statics/docs-ts-model.json +1136 -0
- package/es/build/constants.d.ts +1 -0
- package/es/build/constants.js +1 -0
- package/es/build/index.d.ts +3 -0
- package/es/build/index.js +3 -0
- package/es/build/publisher/publisher.context.d.ts +37 -0
- package/es/build/publisher/publisher.context.js +37 -0
- package/es/build/publisher/publisher.public.d.ts +12 -0
- package/es/build/publisher/publisher.public.js +38 -0
- package/es/build/subscriber/errors.d.ts +16 -0
- package/es/build/subscriber/errors.js +59 -0
- package/es/build/subscriber/subscriber.context.d.ts +4 -0
- package/es/build/subscriber/subscriber.context.js +4 -0
- package/es/build/subscriber/subscriber.public.d.ts +21 -0
- package/es/build/subscriber/subscriber.public.js +229 -0
- package/es/build/subscriber/subscription-manager.d.ts +26 -0
- package/es/build/subscriber/subscription-manager.js +71 -0
- package/es/build/types.d.ts +1 -0
- package/es/build/types.js +1 -0
- package/package.json +34 -30
- package/types/package.json +7 -0
- package/build/cjs/index.d.ts +0 -1
- package/build/es/index.d.ts +0 -1
- package/build/es/index.js +0 -37
- package/build/tsconfig.cjs.tsbuildinfo +0 -1
- package/build/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DUPLEXER_HOST_URL = "duplexer.wix.com";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DUPLEXER_HOST_URL = 'duplexer.wix.com';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { publish as publicPublish } from './publisher.public.js';
|
|
2
|
+
import type { BuildRESTFunction, MaybeContext } from '@wix/sdk-types';
|
|
3
|
+
/**
|
|
4
|
+
* Publishes a message to a realtime channel.
|
|
5
|
+
*
|
|
6
|
+
* Must be called from a backend function (server-to-server) as it requires
|
|
7
|
+
* app-level permissions to publish messages via the Duplexer API.
|
|
8
|
+
*
|
|
9
|
+
* There are two ways to use this function depending on the Wix client type:
|
|
10
|
+
*
|
|
11
|
+
* **Manual client (self-hosted / explicit auth) — no elevation needed:**
|
|
12
|
+
* The client's own credentials (instanceId, metaSiteId) are used directly.
|
|
13
|
+
* This ensures messages are scoped to the correct site.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { publisher } from '@wix/realtime';
|
|
17
|
+
* import { createClient, AppStrategy } from '@wix/sdk';
|
|
18
|
+
*
|
|
19
|
+
* const client = createClient({
|
|
20
|
+
* modules: { publisher },
|
|
21
|
+
* auth: AppStrategy({ appId, appSecret, instanceId }),
|
|
22
|
+
* });
|
|
23
|
+
* await client.publisher.publish({ name: 'my-channel' }, { message: 'hello' });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* **Contextual client (Wix-hosted) — elevation needed:**
|
|
27
|
+
* The contextual httpClient may not have sufficient permissions on its own.
|
|
28
|
+
* Wrap the call with `elevate()` to obtain the host's backend credentials.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { publisher } from '@wix/realtime';
|
|
32
|
+
* import { elevate } from '@wix/sdk/context';
|
|
33
|
+
*
|
|
34
|
+
* await elevate(publisher.publish)({ name: 'my-channel' }, { message: 'hello' });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export declare const publish: MaybeContext<BuildRESTFunction<typeof publicPublish> & typeof publicPublish>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { publish as publicPublish } from './publisher.public.js';
|
|
2
|
+
import { createRESTModule } from '@wix/sdk-runtime/rest-modules';
|
|
3
|
+
/**
|
|
4
|
+
* Publishes a message to a realtime channel.
|
|
5
|
+
*
|
|
6
|
+
* Must be called from a backend function (server-to-server) as it requires
|
|
7
|
+
* app-level permissions to publish messages via the Duplexer API.
|
|
8
|
+
*
|
|
9
|
+
* There are two ways to use this function depending on the Wix client type:
|
|
10
|
+
*
|
|
11
|
+
* **Manual client (self-hosted / explicit auth) — no elevation needed:**
|
|
12
|
+
* The client's own credentials (instanceId, metaSiteId) are used directly.
|
|
13
|
+
* This ensures messages are scoped to the correct site.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* import { publisher } from '@wix/realtime';
|
|
17
|
+
* import { createClient, AppStrategy } from '@wix/sdk';
|
|
18
|
+
*
|
|
19
|
+
* const client = createClient({
|
|
20
|
+
* modules: { publisher },
|
|
21
|
+
* auth: AppStrategy({ appId, appSecret, instanceId }),
|
|
22
|
+
* });
|
|
23
|
+
* await client.publisher.publish({ name: 'my-channel' }, { message: 'hello' });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* **Contextual client (Wix-hosted) — elevation needed:**
|
|
27
|
+
* The contextual httpClient may not have sufficient permissions on its own.
|
|
28
|
+
* Wrap the call with `elevate()` to obtain the host's backend credentials.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { publisher } from '@wix/realtime';
|
|
32
|
+
* import { elevate } from '@wix/sdk/context';
|
|
33
|
+
*
|
|
34
|
+
* await elevate(publisher.publish)({ name: 'my-channel' }, { message: 'hello' });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export const publish = /*#__PURE__*/ createRESTModule(publicPublish);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Channel } from '@wix/realtime-common/types';
|
|
2
|
+
import type { HttpClient } from '@wix/sdk-types';
|
|
3
|
+
interface PublishSignature {
|
|
4
|
+
(channel: Channel, payload: Record<string, any>): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Publish a message to a channel.
|
|
8
|
+
* @param channel - The channel, and optionally the resource, to publish to.
|
|
9
|
+
* @param payload - Message payload. Max: 10kb
|
|
10
|
+
*/
|
|
11
|
+
export declare function publish(httpClient: HttpClient): PublishSignature;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { publish as autoSdkPublish } from '@wix/auto_sdk_duplexer_publisher';
|
|
2
|
+
import { isVeloEnvironment, MESSAGE_EVENT_NAME } from '@wix/realtime-common';
|
|
3
|
+
import * as site from '@wix/sdk_realtime_site';
|
|
4
|
+
import { validateChannel } from '../subscriber/errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Publish a message to a channel.
|
|
7
|
+
* @param channel - The channel, and optionally the resource, to publish to.
|
|
8
|
+
* @param payload - Message payload. Max: 10kb
|
|
9
|
+
*/
|
|
10
|
+
export function publish(httpClient) {
|
|
11
|
+
// autoSdkPublish is the auto-generated Duplexer publish module, wrapped by
|
|
12
|
+
// createRESTModule(descriptor). The raw descriptor has the signature:
|
|
13
|
+
// (httpClient) => (message, options) => Promise<void>
|
|
14
|
+
//
|
|
15
|
+
// We call it directly with our httpClient to bind the publish function to
|
|
16
|
+
// this client's auth credentials. This ensures the correct instanceId and
|
|
17
|
+
// metaSiteId are used in the publish request — critical for site-level
|
|
18
|
+
// message isolation in multi-site scenarios.
|
|
19
|
+
//
|
|
20
|
+
// Without this binding, the auto-generated module would resolve its own
|
|
21
|
+
// httpClient via the global Wix context, which always returns the host
|
|
22
|
+
// site's credentials — ignoring any manual client auth provided via
|
|
23
|
+
// createClient({ auth: AppStrategy({...}) }).
|
|
24
|
+
//
|
|
25
|
+
// For contextual usage (no explicit auth), the caller should wrap this
|
|
26
|
+
// function with elevate() to obtain the host's backend credentials:
|
|
27
|
+
// elevate(realtimePublisher.publish)(channel, payload)
|
|
28
|
+
const boundPublish = autoSdkPublish(httpClient);
|
|
29
|
+
return (channel, payload) => {
|
|
30
|
+
validateChannel(channel);
|
|
31
|
+
if (isVeloEnvironment()) {
|
|
32
|
+
return Promise.resolve(site.publish(channel, payload));
|
|
33
|
+
}
|
|
34
|
+
return boundPublish({ eventName: MESSAGE_EVENT_NAME, payload }, { channels: [channel.name], resourceId: channel.resourceId }).catch((error) => {
|
|
35
|
+
throw new Error(`Failed to publish: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { RealtimeError, type Channel } from '@wix/realtime-common/types';
|
|
2
|
+
export declare function missingChannelOrSubscriptionId(): RealtimeError;
|
|
3
|
+
export declare function channelMismatch(): RealtimeError;
|
|
4
|
+
export declare function invalidChannel(channel: Channel): RealtimeError;
|
|
5
|
+
export declare function validateChannel(channel: Channel): void;
|
|
6
|
+
export declare function disconnectedError(recoverable?: boolean, message?: string): RealtimeError;
|
|
7
|
+
export declare function connectError(recoverable?: boolean, message?: string): RealtimeError;
|
|
8
|
+
export declare function subscribeFailed(channel: Channel, payload?: {
|
|
9
|
+
status?: number;
|
|
10
|
+
message?: string;
|
|
11
|
+
}): RealtimeError;
|
|
12
|
+
export declare function resubscribeFailed(channel: Channel, payload?: {
|
|
13
|
+
status?: number;
|
|
14
|
+
message?: string;
|
|
15
|
+
}): RealtimeError;
|
|
16
|
+
export declare function subscribeError(error: unknown, channel: Channel): RealtimeError;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { RealtimeError, RealtimeErrorCode, } from '@wix/realtime-common/types';
|
|
2
|
+
export function missingChannelOrSubscriptionId() {
|
|
3
|
+
return new RealtimeError(RealtimeErrorCode.BadInput, 'channel or subscriptionId is required');
|
|
4
|
+
}
|
|
5
|
+
export function channelMismatch() {
|
|
6
|
+
return new RealtimeError(RealtimeErrorCode.BadInput, 'channel does not match subscriptionId');
|
|
7
|
+
}
|
|
8
|
+
import { CHANNEL_NAME_MAX_LENGTH, CHANNEL_NAME_PATTERN, } from '@wix/realtime-common';
|
|
9
|
+
export function invalidChannel(channel) {
|
|
10
|
+
const { name, resourceId } = channel;
|
|
11
|
+
const reasons = [];
|
|
12
|
+
if (!name) {
|
|
13
|
+
reasons.push('name must not be empty');
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
if (!CHANNEL_NAME_PATTERN.test(name)) {
|
|
17
|
+
reasons.push('name must contain only alphanumeric characters, hyphens, or underscores');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const combinedLength = (name?.length ?? 0) + (resourceId?.length ?? 0);
|
|
21
|
+
if (combinedLength > CHANNEL_NAME_MAX_LENGTH) {
|
|
22
|
+
reasons.push(`combined channel name and resourceId length is ${combinedLength} characters (max ${CHANNEL_NAME_MAX_LENGTH})`);
|
|
23
|
+
}
|
|
24
|
+
return new RealtimeError(RealtimeErrorCode.BadInput, `Invalid channel "${name}": ${reasons.join('; ')}.`);
|
|
25
|
+
}
|
|
26
|
+
export function validateChannel(channel) {
|
|
27
|
+
const { name, resourceId } = channel;
|
|
28
|
+
const combinedLength = (name?.length ?? 0) + (resourceId?.length ?? 0);
|
|
29
|
+
if (!name ||
|
|
30
|
+
!CHANNEL_NAME_PATTERN.test(name) ||
|
|
31
|
+
combinedLength > CHANNEL_NAME_MAX_LENGTH) {
|
|
32
|
+
throw invalidChannel(channel);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function disconnectedError(recoverable, message) {
|
|
36
|
+
return new RealtimeError(RealtimeErrorCode.Disconnected, message || 'disconnected', { recoverable });
|
|
37
|
+
}
|
|
38
|
+
export function connectError(recoverable, message) {
|
|
39
|
+
return new RealtimeError(RealtimeErrorCode.ConnectionError, message || 'connection error', { recoverable });
|
|
40
|
+
}
|
|
41
|
+
function subscriptionFailed(prefix, channel, payload) {
|
|
42
|
+
const msg = payload?.message
|
|
43
|
+
? `${prefix} failed: ${payload.message} (status ${payload.status})`
|
|
44
|
+
: `${prefix} failed`;
|
|
45
|
+
return new RealtimeError(RealtimeErrorCode.SubscriptionFailed, msg, {
|
|
46
|
+
channel,
|
|
47
|
+
recoverable: false,
|
|
48
|
+
status: payload?.status,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function subscribeFailed(channel, payload) {
|
|
52
|
+
return subscriptionFailed('subscribe', channel, payload);
|
|
53
|
+
}
|
|
54
|
+
export function resubscribeFailed(channel, payload) {
|
|
55
|
+
return subscriptionFailed('resubscribe', channel, payload);
|
|
56
|
+
}
|
|
57
|
+
export function subscribeError(error, channel) {
|
|
58
|
+
return new RealtimeError(RealtimeErrorCode.SubscriptionFailed, error instanceof Error ? error.message : 'subscribe failed', { channel, recoverable: false });
|
|
59
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { subscribe as publicSubscribe, unsubscribe as publicUnsubscribe } from './subscriber.public.js';
|
|
2
|
+
import type { BuildRESTFunction, MaybeContext } from '@wix/sdk-types';
|
|
3
|
+
export declare const subscribe: MaybeContext<BuildRESTFunction<typeof publicSubscribe> & typeof publicSubscribe>;
|
|
4
|
+
export declare const unsubscribe: MaybeContext<BuildRESTFunction<typeof publicUnsubscribe> & typeof publicUnsubscribe>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { subscribe as publicSubscribe, unsubscribe as publicUnsubscribe, } from './subscriber.public.js';
|
|
2
|
+
import { createRESTModule } from '@wix/sdk-runtime/rest-modules';
|
|
3
|
+
export const subscribe = /*#__PURE__*/ createRESTModule(publicSubscribe);
|
|
4
|
+
export const unsubscribe = /*#__PURE__*/ createRESTModule(publicUnsubscribe);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Channel, type MessageHandler, type SubscriptionOptions, type UnsubscribeOptions } from '@wix/realtime-common/types';
|
|
2
|
+
import type { HttpClient } from '@wix/sdk-types';
|
|
3
|
+
interface SubscribeSignature {
|
|
4
|
+
(channel: Channel, messageHandler: MessageHandler, options?: SubscriptionOptions): string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to a channel. Returns a subscriptionId immediately; the
|
|
8
|
+
* actual connection is established asynchronously in the background. Use the
|
|
9
|
+
* optional `options` parameter to handle subscription lifecycle events.
|
|
10
|
+
*/
|
|
11
|
+
export declare function subscribe(httpClient: HttpClient): SubscribeSignature;
|
|
12
|
+
interface UnsubscribeSignature {
|
|
13
|
+
(options: UnsubscribeOptions): void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Unsubscribe from a subscription by its ID, by channel, or both.
|
|
17
|
+
*/
|
|
18
|
+
export declare function unsubscribe(_: HttpClient): UnsubscribeSignature;
|
|
19
|
+
/** @internal Resets all module-level state. Only for use in tests. */
|
|
20
|
+
export declare function _resetForTesting(): void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Duplexer } from '@wix/duplexer-js';
|
|
2
|
+
import { DUPLEXER_EVENTS, MESSAGE_EVENT_NAME, getAppInfoFromAuthToken, tokenToAppInfoCache, getAuthToken, } from '@wix/realtime-common';
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
4
|
+
import { DUPLEXER_HOST_URL } from '../constants.js';
|
|
5
|
+
import { missingChannelOrSubscriptionId, channelMismatch, disconnectedError, connectError, subscribeFailed, resubscribeFailed, subscribeError, validateChannel, } from './errors.js';
|
|
6
|
+
import { SubscriptionManager } from './subscription-manager.js';
|
|
7
|
+
const instanceConnections = new Map();
|
|
8
|
+
const subscriptionManager = new SubscriptionManager();
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe to a channel. Returns a subscriptionId immediately; the
|
|
11
|
+
* actual connection is established asynchronously in the background. Use the
|
|
12
|
+
* optional `options` parameter to handle subscription lifecycle events.
|
|
13
|
+
*/
|
|
14
|
+
export function subscribe(httpClient) {
|
|
15
|
+
return (channel, messageHandler, options) => {
|
|
16
|
+
validateChannel(channel);
|
|
17
|
+
const subscriptionId = `sub_${uuidv4()}`;
|
|
18
|
+
subscriptionManager.set(subscriptionId, {
|
|
19
|
+
channelName: channel.name,
|
|
20
|
+
channelResourceId: channel.resourceId,
|
|
21
|
+
removeListeners: () => { },
|
|
22
|
+
onSubscribed: options?.onSubscribed,
|
|
23
|
+
onSubscriptionError: options?.onSubscriptionError,
|
|
24
|
+
});
|
|
25
|
+
void setupSubscription(httpClient, subscriptionId, channel, messageHandler);
|
|
26
|
+
return subscriptionId;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Unsubscribe from a subscription by its ID, by channel, or both.
|
|
31
|
+
*/
|
|
32
|
+
export function unsubscribe(_) {
|
|
33
|
+
return ({ channel, subscriptionId }) => {
|
|
34
|
+
if (!channel && !subscriptionId) {
|
|
35
|
+
throw missingChannelOrSubscriptionId();
|
|
36
|
+
}
|
|
37
|
+
if (subscriptionId) {
|
|
38
|
+
unsubscribeById(subscriptionId, channel);
|
|
39
|
+
}
|
|
40
|
+
else if (channel) {
|
|
41
|
+
unsubscribeByChannel(channel);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** @internal Resets all module-level state. Only for use in tests. */
|
|
46
|
+
export function _resetForTesting() {
|
|
47
|
+
subscriptionManager.removeAll();
|
|
48
|
+
for (const { socket, duplexer } of instanceConnections.values()) {
|
|
49
|
+
socket.disconnect();
|
|
50
|
+
duplexer.close();
|
|
51
|
+
}
|
|
52
|
+
instanceConnections.clear();
|
|
53
|
+
tokenToAppInfoCache.clear();
|
|
54
|
+
}
|
|
55
|
+
function unsubscribeById(subscriptionId, channel) {
|
|
56
|
+
const subscription = subscriptionManager.get(subscriptionId);
|
|
57
|
+
if (!subscription) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (channel &&
|
|
61
|
+
(channel.name !== subscription.channelName ||
|
|
62
|
+
channel.resourceId !== subscription.channelResourceId)) {
|
|
63
|
+
throw channelMismatch();
|
|
64
|
+
}
|
|
65
|
+
subscription.removeListeners();
|
|
66
|
+
const { channelName, channelResourceId, instanceId, channelEmitter } = subscription;
|
|
67
|
+
// If subscription is still pending (no channelEmitter/instanceId), mark
|
|
68
|
+
// cancelled so the in-flight setupSubscription knows to bail out.
|
|
69
|
+
if (!channelEmitter || !instanceId) {
|
|
70
|
+
subscription.cancelled = true;
|
|
71
|
+
subscriptionManager.delete(subscriptionId);
|
|
72
|
+
if (instanceId) {
|
|
73
|
+
cleanupConnectionIfIdle(instanceId);
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// If no other subscriptions remain on this channel, fire-and-forget
|
|
78
|
+
// the duplexer unsubscribe.
|
|
79
|
+
const hasOtherSubscriptionsOnChannel = subscriptionManager.deleteAndStillHasRemainingOnChannel(instanceId, subscriptionId, channelName, channelResourceId);
|
|
80
|
+
if (!hasOtherSubscriptionsOnChannel) {
|
|
81
|
+
const conn = instanceConnections.get(instanceId);
|
|
82
|
+
if (conn) {
|
|
83
|
+
conn.socket.unsubscribe(channelName, channelResourceId);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
cleanupConnectionIfIdle(instanceId);
|
|
87
|
+
}
|
|
88
|
+
function unsubscribeByChannel(channel) {
|
|
89
|
+
const entriesToRemove = subscriptionManager.findByChannel(channel.name, channel.resourceId);
|
|
90
|
+
for (const [id] of entriesToRemove) {
|
|
91
|
+
unsubscribeById(id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function closeConnection(instanceId) {
|
|
95
|
+
const conn = instanceConnections.get(instanceId);
|
|
96
|
+
if (conn) {
|
|
97
|
+
conn.socket.removeAllListeners();
|
|
98
|
+
conn.socket.disconnect();
|
|
99
|
+
conn.duplexer.close();
|
|
100
|
+
instanceConnections.delete(instanceId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Clean up the connection for an instanceId if it has no remaining subscriptions. */
|
|
104
|
+
function cleanupConnectionIfIdle(instanceId) {
|
|
105
|
+
if (!subscriptionManager.hasSubscriptionsForInstanceId(instanceId)) {
|
|
106
|
+
closeConnection(instanceId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function getOrCreateConnection(httpClient, appId, instanceId) {
|
|
110
|
+
const existing = instanceConnections.get(instanceId);
|
|
111
|
+
if (existing) {
|
|
112
|
+
return existing;
|
|
113
|
+
}
|
|
114
|
+
const duplexer = new Duplexer(DUPLEXER_HOST_URL, {
|
|
115
|
+
instanceUpdater: {
|
|
116
|
+
getInstance: getAuthToken.bind(null, httpClient),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const socket = duplexer.connect({
|
|
120
|
+
appDefId: appId,
|
|
121
|
+
});
|
|
122
|
+
const notifySubscriptionError = (realtimeError) => {
|
|
123
|
+
for (const subId of subscriptionManager.getSubscriptionIdsByInstanceId(instanceId)) {
|
|
124
|
+
subscriptionManager
|
|
125
|
+
.get(subId)
|
|
126
|
+
?.onSubscriptionError?.(realtimeError, subId);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
socket.on(DUPLEXER_EVENTS.disconnected, (error) => {
|
|
130
|
+
notifySubscriptionError(disconnectedError(error?.recoverable, error?.message));
|
|
131
|
+
});
|
|
132
|
+
socket.on(DUPLEXER_EVENTS.connectError, (error) => {
|
|
133
|
+
notifySubscriptionError(connectError(error?.recoverable, error?.message));
|
|
134
|
+
});
|
|
135
|
+
const connection = { duplexer, socket };
|
|
136
|
+
instanceConnections.set(instanceId, connection);
|
|
137
|
+
return connection;
|
|
138
|
+
}
|
|
139
|
+
async function setupSubscription(httpClient, subscriptionId, channel, messageHandler) {
|
|
140
|
+
try {
|
|
141
|
+
const { appId, instanceId } = await getAppInfoFromAuthToken(httpClient);
|
|
142
|
+
const subscriptionEntry = subscriptionManager.get(subscriptionId);
|
|
143
|
+
// Entry was already deleted (e.g., unsubscribe called during the await)
|
|
144
|
+
// or explicitly cancelled — bail out.
|
|
145
|
+
if (!subscriptionEntry || subscriptionEntry.cancelled) {
|
|
146
|
+
subscriptionManager.delete(subscriptionId);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// Index the subscription by instanceId now so cleanupConnectionIfIdle
|
|
150
|
+
// correctly counts it while the duplexer subscription is being set up.
|
|
151
|
+
subscriptionManager.set(subscriptionId, {
|
|
152
|
+
...subscriptionEntry,
|
|
153
|
+
instanceId,
|
|
154
|
+
});
|
|
155
|
+
const { socket } = getOrCreateConnection(httpClient, appId, instanceId);
|
|
156
|
+
const onMessageCallback = (payload, { publisher }) => {
|
|
157
|
+
messageHandler({
|
|
158
|
+
payload,
|
|
159
|
+
publisher: publisher?.uid ? { id: publisher.uid } : undefined,
|
|
160
|
+
}, channel);
|
|
161
|
+
};
|
|
162
|
+
// --- callback definitions (hoisted above subscribe for #6 race fix) ---
|
|
163
|
+
function onSubscribeSucceeded(payload) {
|
|
164
|
+
subscribedChannel.off(DUPLEXER_EVENTS.subscriptionFailed, onSubscribeFailed);
|
|
165
|
+
// If the subscription was removed (via unsubscribe) while waiting for
|
|
166
|
+
// the duplexer to confirm, clean up the duplexer channel to avoid a
|
|
167
|
+
// server-side subscription leak.
|
|
168
|
+
if (!subscriptionManager.get(subscriptionId)) {
|
|
169
|
+
socket.unsubscribe(channel.name, channel.resourceId);
|
|
170
|
+
cleanupConnectionIfIdle(instanceId);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
subscribedChannel.on(MESSAGE_EVENT_NAME, onMessageCallback);
|
|
174
|
+
// Fire onSubscribed for the initial subscription — re-read from the
|
|
175
|
+
// manager to avoid using a stale closure reference.
|
|
176
|
+
subscriptionManager
|
|
177
|
+
.get(subscriptionId)
|
|
178
|
+
?.onSubscribed?.(subscriptionId, payload?.isSynced ?? false);
|
|
179
|
+
// Listen for resubscription succeeded (fires after reconnection)
|
|
180
|
+
const onResubscribeSucceeded = (payload) => {
|
|
181
|
+
if (subscriptionManager.get(subscriptionId)) {
|
|
182
|
+
subscriptionManager
|
|
183
|
+
.get(subscriptionId)
|
|
184
|
+
?.onSubscribed?.(subscriptionId, payload?.isSynced ?? false);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
subscribedChannel.on(DUPLEXER_EVENTS.subscriptionSucceeded, onResubscribeSucceeded);
|
|
188
|
+
const onResubscribeFailed = (payload) => {
|
|
189
|
+
subscriptionManager
|
|
190
|
+
.get(subscriptionId)
|
|
191
|
+
?.onSubscriptionError?.(resubscribeFailed(channel, payload), subscriptionId);
|
|
192
|
+
};
|
|
193
|
+
subscribedChannel.on(DUPLEXER_EVENTS.subscriptionFailed, onResubscribeFailed);
|
|
194
|
+
const currentEntry = subscriptionManager.get(subscriptionId);
|
|
195
|
+
subscriptionManager.set(subscriptionId, {
|
|
196
|
+
channelName: channel.name,
|
|
197
|
+
channelResourceId: channel.resourceId,
|
|
198
|
+
instanceId,
|
|
199
|
+
channelEmitter: subscribedChannel,
|
|
200
|
+
onSubscribed: currentEntry?.onSubscribed,
|
|
201
|
+
onSubscriptionError: currentEntry?.onSubscriptionError,
|
|
202
|
+
removeListeners: () => {
|
|
203
|
+
subscribedChannel.off(MESSAGE_EVENT_NAME, onMessageCallback);
|
|
204
|
+
subscribedChannel.off(DUPLEXER_EVENTS.subscriptionSucceeded, onResubscribeSucceeded);
|
|
205
|
+
subscribedChannel.off(DUPLEXER_EVENTS.subscriptionFailed, onResubscribeFailed);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function onSubscribeFailed(payload) {
|
|
210
|
+
subscribedChannel.off(DUPLEXER_EVENTS.subscriptionSucceeded, onSubscribeSucceeded);
|
|
211
|
+
const failedEntry = subscriptionManager.get(subscriptionId);
|
|
212
|
+
subscriptionManager.delete(subscriptionId);
|
|
213
|
+
failedEntry?.onSubscriptionError?.(subscribeFailed(channel, payload), subscriptionId);
|
|
214
|
+
cleanupConnectionIfIdle(instanceId);
|
|
215
|
+
}
|
|
216
|
+
// Subscribe to the duplexer channel and register lifecycle listeners
|
|
217
|
+
// in the same synchronous block so no events can be missed.
|
|
218
|
+
const subscribedChannel = socket.subscribe(channel.name, {
|
|
219
|
+
resourceId: channel.resourceId,
|
|
220
|
+
});
|
|
221
|
+
subscribedChannel.once(DUPLEXER_EVENTS.subscriptionSucceeded, onSubscribeSucceeded);
|
|
222
|
+
subscribedChannel.once(DUPLEXER_EVENTS.subscriptionFailed, onSubscribeFailed);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
const sub = subscriptionManager.get(subscriptionId);
|
|
226
|
+
subscriptionManager.delete(subscriptionId);
|
|
227
|
+
sub?.onSubscriptionError?.(subscribeError(error, channel), subscriptionId);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Duplexer } from '@wix/duplexer-js';
|
|
2
|
+
import type { SubscriptionHandler, SubscriptionErrorHandler } from '@wix/realtime-common/types';
|
|
3
|
+
export interface SubscriptionEntry {
|
|
4
|
+
channelName: string;
|
|
5
|
+
channelResourceId?: string;
|
|
6
|
+
instanceId?: string;
|
|
7
|
+
channelEmitter?: ReturnType<ReturnType<Duplexer['connect']>['subscribe']>;
|
|
8
|
+
removeListeners: () => void;
|
|
9
|
+
cancelled?: boolean;
|
|
10
|
+
onSubscribed?: SubscriptionHandler;
|
|
11
|
+
onSubscriptionError?: SubscriptionErrorHandler;
|
|
12
|
+
}
|
|
13
|
+
export declare class SubscriptionManager {
|
|
14
|
+
private subscriptionsBySubId;
|
|
15
|
+
private instanceIdToSubscriptionIds;
|
|
16
|
+
set(id: string, entry: SubscriptionEntry): void;
|
|
17
|
+
get(id: string): SubscriptionEntry | undefined;
|
|
18
|
+
delete(id: string): void;
|
|
19
|
+
findByChannel(channelName: string, channelResourceId?: string): [string, SubscriptionEntry][];
|
|
20
|
+
deleteAndStillHasRemainingOnChannel(instanceId: string, subscriptionIdToDelete: string, channelName: string, resourceId?: string): boolean;
|
|
21
|
+
hasSubscriptionsForInstanceId(instanceId: string): boolean;
|
|
22
|
+
getSubscriptionIdsByInstanceId(instanceId: string): string[];
|
|
23
|
+
removeAll(): void;
|
|
24
|
+
private addToInstanceIndex;
|
|
25
|
+
private removeFromInstanceIndex;
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export class SubscriptionManager {
|
|
2
|
+
subscriptionsBySubId = new Map();
|
|
3
|
+
instanceIdToSubscriptionIds = new Map();
|
|
4
|
+
set(id, entry) {
|
|
5
|
+
this.subscriptionsBySubId.set(id, entry);
|
|
6
|
+
if (entry.instanceId) {
|
|
7
|
+
this.addToInstanceIndex(entry.instanceId, id);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
get(id) {
|
|
11
|
+
return this.subscriptionsBySubId.get(id);
|
|
12
|
+
}
|
|
13
|
+
delete(id) {
|
|
14
|
+
const entry = this.subscriptionsBySubId.get(id);
|
|
15
|
+
if (entry?.instanceId) {
|
|
16
|
+
this.removeFromInstanceIndex(entry.instanceId, id);
|
|
17
|
+
}
|
|
18
|
+
this.subscriptionsBySubId.delete(id);
|
|
19
|
+
}
|
|
20
|
+
findByChannel(channelName, channelResourceId) {
|
|
21
|
+
return [...this.subscriptionsBySubId.entries()].filter(([, sub]) => sub.channelName === channelName &&
|
|
22
|
+
sub.channelResourceId === channelResourceId);
|
|
23
|
+
}
|
|
24
|
+
deleteAndStillHasRemainingOnChannel(instanceId, subscriptionIdToDelete, channelName, resourceId) {
|
|
25
|
+
this.delete(subscriptionIdToDelete);
|
|
26
|
+
const candidateIds = this.instanceIdToSubscriptionIds.get(instanceId);
|
|
27
|
+
if (!candidateIds)
|
|
28
|
+
return false;
|
|
29
|
+
for (const id of candidateIds) {
|
|
30
|
+
const sub = this.subscriptionsBySubId.get(id);
|
|
31
|
+
if (sub &&
|
|
32
|
+
sub.channelName === channelName &&
|
|
33
|
+
sub.channelResourceId === resourceId) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
hasSubscriptionsForInstanceId(instanceId) {
|
|
40
|
+
const ids = this.instanceIdToSubscriptionIds.get(instanceId);
|
|
41
|
+
return !!ids && ids.size > 0;
|
|
42
|
+
}
|
|
43
|
+
getSubscriptionIdsByInstanceId(instanceId) {
|
|
44
|
+
const ids = this.instanceIdToSubscriptionIds.get(instanceId);
|
|
45
|
+
return ids ? [...ids] : [];
|
|
46
|
+
}
|
|
47
|
+
removeAll() {
|
|
48
|
+
for (const [, sub] of this.subscriptionsBySubId) {
|
|
49
|
+
sub.removeListeners();
|
|
50
|
+
}
|
|
51
|
+
this.subscriptionsBySubId.clear();
|
|
52
|
+
this.instanceIdToSubscriptionIds.clear();
|
|
53
|
+
}
|
|
54
|
+
addToInstanceIndex(instanceId, id) {
|
|
55
|
+
let ids = this.instanceIdToSubscriptionIds.get(instanceId);
|
|
56
|
+
if (!ids) {
|
|
57
|
+
ids = new Set();
|
|
58
|
+
this.instanceIdToSubscriptionIds.set(instanceId, ids);
|
|
59
|
+
}
|
|
60
|
+
ids.add(id);
|
|
61
|
+
}
|
|
62
|
+
removeFromInstanceIndex(instanceId, id) {
|
|
63
|
+
const ids = this.instanceIdToSubscriptionIds.get(instanceId);
|
|
64
|
+
if (ids) {
|
|
65
|
+
ids.delete(id);
|
|
66
|
+
if (ids.size === 0) {
|
|
67
|
+
this.instanceIdToSubscriptionIds.delete(instanceId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RealtimeErrorCode, RealtimeError, type Channel, type Publisher, type Message, type MessageHandler, type SubscriptionHandler, type SubscriptionErrorHandler, type SubscriptionOptions, } from '@wix/realtime-common/types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RealtimeErrorCode, RealtimeError, } from '@wix/realtime-common/types';
|