@foony/realtime 0.0.1

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/lib/wire.d.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Wire protocol types for the Foony Realtime WebSocket service.
3
+ *
4
+ * Mirrors `services/realtime-saas/internal/wire/wire.go` exactly — any
5
+ * change here MUST be mirrored on the Go side and vice versa.
6
+ *
7
+ * Conventions:
8
+ * - Every frame has a single-character `t` discriminator.
9
+ * - Client-originated frames carry a numeric `id`; the server echoes it
10
+ * back on the matching `ack` / `err` frame so SDKs can correlate
11
+ * requests to responses.
12
+ * - Field names favor readability over brevity (`channel`, not `ch`),
13
+ * except for `t`, which we keep short because every frame carries it.
14
+ */
15
+ /** Discriminator values for the `t` field. */
16
+ export type FrameType = 'auth' | 'sub' | 'unsub' | 'pub' | 'pres' | 'hist' | 'ping' | 'connected' | 'ack' | 'msg' | 'presEvt' | 'err' | 'pong' | 'histRes';
17
+ /** Recognized presence transition values. */
18
+ export type PresenceAction = 'enter' | 'leave' | 'update';
19
+ /** First frame after the WebSocket handshake. Carries either a JWT token or API key. */
20
+ export type AuthFrame = {
21
+ readonly t: 'auth';
22
+ readonly token?: string;
23
+ readonly key?: string;
24
+ readonly clientId?: string;
25
+ };
26
+ /** Start delivering messages + presence for `channel`. */
27
+ export type SubscribeFrame = {
28
+ readonly t: 'sub';
29
+ readonly channel: string;
30
+ readonly id: number;
31
+ /** Optional resume cursor; when set, replay messages with id > this. */
32
+ readonly lastMessageId?: string;
33
+ };
34
+ /** Stop delivering messages + presence for `channel`. */
35
+ export type UnsubscribeFrame = {
36
+ readonly t: 'unsub';
37
+ readonly channel: string;
38
+ readonly id: number;
39
+ };
40
+ /** Publish a single application-level message to `channel`. */
41
+ export type PublishFrame = {
42
+ readonly t: 'pub';
43
+ readonly channel: string;
44
+ readonly name: string;
45
+ readonly data: unknown;
46
+ readonly id: number;
47
+ };
48
+ /** Mutate the publisher's presence membership in `channel`. */
49
+ export type PresenceFrame = {
50
+ readonly t: 'pres';
51
+ readonly channel: string;
52
+ readonly action: PresenceAction;
53
+ readonly data?: unknown;
54
+ readonly id: number;
55
+ };
56
+ /** Application-level liveness probe. Server replies with `pong`. */
57
+ export type PingFrame = {
58
+ readonly t: 'ping';
59
+ };
60
+ /** Sent once after a successful auth handshake. */
61
+ export type ConnectedFrame = {
62
+ readonly t: 'connected';
63
+ readonly connectionId: string;
64
+ readonly keepAliveMs: number;
65
+ readonly clientId: string;
66
+ };
67
+ /** Acks a client request that does not need a structured reply. */
68
+ export type AckFrame = {
69
+ readonly t: 'ack';
70
+ readonly id: number;
71
+ };
72
+ /** Server-originated channel message. */
73
+ export type MessageFrame = {
74
+ readonly t: 'msg';
75
+ readonly channel: string;
76
+ readonly name: string;
77
+ readonly data: unknown;
78
+ readonly timestamp: number;
79
+ readonly messageId: string;
80
+ readonly clientId?: string;
81
+ };
82
+ /** Server-originated presence transition. */
83
+ export type PresenceEventFrame = {
84
+ readonly t: 'presEvt';
85
+ readonly channel: string;
86
+ readonly action: PresenceAction;
87
+ readonly clientId: string;
88
+ readonly connectionId: string;
89
+ readonly data?: unknown;
90
+ readonly timestamp: number;
91
+ };
92
+ /** Protocol or authorization error related to a specific client request. */
93
+ export type ErrorFrame = {
94
+ readonly t: 'err';
95
+ readonly id?: number;
96
+ readonly code: number;
97
+ readonly message: string;
98
+ };
99
+ /** Response to `ping`. */
100
+ export type PongFrame = {
101
+ readonly t: 'pong';
102
+ };
103
+ /** Response to `hist`. Messages oldest-first. */
104
+ export type HistoryResponseFrame = {
105
+ readonly t: 'histRes';
106
+ readonly id: number;
107
+ readonly channel: string;
108
+ readonly messages: readonly MessageFrame[];
109
+ readonly more?: boolean;
110
+ };
111
+ /** Any frame the client may send. */
112
+ export type ClientFrame = AuthFrame | SubscribeFrame | UnsubscribeFrame | PublishFrame | PresenceFrame | PingFrame;
113
+ /** Any frame the server may send. */
114
+ export type ServerFrame = ConnectedFrame | AckFrame | MessageFrame | PresenceEventFrame | ErrorFrame | PongFrame | HistoryResponseFrame;
115
+ /**
116
+ * Error codes the server uses on `err` frames. Mirrors the Go
117
+ * `internal/wire` constants — keep in sync.
118
+ */
119
+ export declare const ErrorCode: {
120
+ readonly BadFrame: 40001;
121
+ readonly BadAuth: 40101;
122
+ readonly AuthExpired: 40102;
123
+ readonly Forbidden: 40300;
124
+ readonly NotFound: 40400;
125
+ readonly Server: 50000;
126
+ };
127
+ export type ErrorCodeName = keyof typeof ErrorCode;
128
+ //# sourceMappingURL=wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire.d.ts","sourceRoot":"","sources":["../src/wire.ts"],"names":[],"mappings":"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"}
package/lib/wire.js ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Wire protocol types for the Foony Realtime WebSocket service.
3
+ *
4
+ * Mirrors `services/realtime-saas/internal/wire/wire.go` exactly — any
5
+ * change here MUST be mirrored on the Go side and vice versa.
6
+ *
7
+ * Conventions:
8
+ * - Every frame has a single-character `t` discriminator.
9
+ * - Client-originated frames carry a numeric `id`; the server echoes it
10
+ * back on the matching `ack` / `err` frame so SDKs can correlate
11
+ * requests to responses.
12
+ * - Field names favor readability over brevity (`channel`, not `ch`),
13
+ * except for `t`, which we keep short because every frame carries it.
14
+ */
15
+ /**
16
+ * Error codes the server uses on `err` frames. Mirrors the Go
17
+ * `internal/wire` constants — keep in sync.
18
+ */
19
+ export const ErrorCode = {
20
+ BadFrame: 40001,
21
+ BadAuth: 40101,
22
+ AuthExpired: 40102,
23
+ Forbidden: 40300,
24
+ NotFound: 40400,
25
+ Server: 50000,
26
+ };
27
+ //# sourceMappingURL=wire.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire.js","sourceRoot":"","sources":["../src/wire.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAsJH;;;GAGG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,QAAQ,EAAE,KAAK;IACf,OAAO,EAAE,KAAK;IACd,WAAW,EAAE,KAAK;IAClB,SAAS,EAAE,KAAK;IAChB,QAAQ,EAAE,KAAK;IACf,MAAM,EAAE,KAAK;CACL,CAAC"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@foony/realtime",
3
+ "version": "0.0.1",
4
+ "description": "TypeScript SDK for the Foony Realtime service.",
5
+ "license": "GPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Foony-Limited/realtime-js.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/Foony-Limited/realtime-js/issues"
12
+ },
13
+ "homepage": "https://github.com/Foony-Limited/realtime-js#readme",
14
+ "keywords": [
15
+ "foony",
16
+ "realtime",
17
+ "websocket",
18
+ "typescript"
19
+ ],
20
+ "type": "module",
21
+ "main": "./lib/index.js",
22
+ "types": "./lib/index.d.ts",
23
+ "sideEffects": false,
24
+ "files": [
25
+ "lib",
26
+ "src/**/*.ts",
27
+ "!src/**/*.test.ts"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./lib/index.d.ts",
32
+ "import": "./lib/index.js"
33
+ },
34
+ "./server": {
35
+ "types": "./lib/server.d.ts",
36
+ "import": "./lib/server.js"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsc",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.13.0",
49
+ "@types/ws": "^8.5.13",
50
+ "typescript": "6.0.3",
51
+ "vitest": "^4.0.14",
52
+ "ws": "^8.18.0"
53
+ },
54
+ "engines": {
55
+ "node": ">=22"
56
+ }
57
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Channel + Presence public API. Wraps the Connection layer with
3
+ * per-channel state.
4
+ */
5
+
6
+ import type { Connection, MessageListener, PresenceEventListener } from './connection.js';
7
+ import type { PresenceAction } from './wire.js';
8
+
9
+ /** Listener handle returned by `subscribe` — call to remove the listener. */
10
+ export type UnsubscribeFn = () => void;
11
+
12
+ /**
13
+ * One subscription handle per (channel, listener) pair. Channels are
14
+ * value-equal by name on a given Realtime client — calling
15
+ * `client.channels.get('chat:1')` twice returns the same instance.
16
+ */
17
+ export class Channel {
18
+ readonly name: string;
19
+ readonly presence: Presence;
20
+ private readonly connection: Connection;
21
+ private attachPromise: Promise<void> | null = null;
22
+ private attached = false;
23
+
24
+ constructor(connection: Connection, name: string) {
25
+ this.connection = connection;
26
+ this.name = name;
27
+ this.presence = new Presence(connection, name, this);
28
+ }
29
+
30
+ /**
31
+ * Ensure the server is subscribed to this channel. Called implicitly
32
+ * by `subscribe()` and `presence.subscribe()`; expose it so callers
33
+ * can pre-attach if they want to surface attach errors before the
34
+ * first message arrives.
35
+ */
36
+ async attach(): Promise<void> {
37
+ if (this.attached) return;
38
+ if (this.attachPromise) return this.attachPromise;
39
+ this.attachPromise = this.connection
40
+ .request({ t: 'sub', channel: this.name })
41
+ .then(() => {
42
+ this.attached = true;
43
+ this.connection.rememberSubscription(this.name);
44
+ })
45
+ .finally(() => {
46
+ this.attachPromise = null;
47
+ });
48
+ return this.attachPromise;
49
+ }
50
+
51
+ /**
52
+ * Detach from the server (stop receiving messages and presence
53
+ * events). Local listeners are preserved — call `unsubscribe()` to
54
+ * clear them.
55
+ */
56
+ async detach(): Promise<void> {
57
+ if (!this.attached) return;
58
+ await this.connection.request({ t: 'unsub', channel: this.name });
59
+ this.attached = false;
60
+ this.connection.forgetSubscription(this.name);
61
+ }
62
+
63
+ /**
64
+ * Register a listener for message frames on this channel. Implicitly
65
+ * attaches if needed. Returns an unsubscribe function.
66
+ */
67
+ subscribe(listener: MessageListener): UnsubscribeFn {
68
+ const listeners = this.connection.addChannelListeners(this.name);
69
+ listeners.messages.add(listener);
70
+ // Fire-and-forget attach; the listener stays registered even if
71
+ // attach fails so a retry-on-reconnect surfaces the right state.
72
+ this.attach().catch(() => {});
73
+ return () => {
74
+ listeners.messages.delete(listener);
75
+ };
76
+ }
77
+
78
+ /** Publish one application-level message to the channel. */
79
+ async publish(name: string, data: unknown): Promise<void> {
80
+ await this.attach();
81
+ await this.connection.request({ t: 'pub', channel: this.name, name, data });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Per-channel presence facade. Wraps the `pres` frame and `presEvt`
87
+ * listener dispatch.
88
+ */
89
+ export class Presence {
90
+ private readonly connection: Connection;
91
+ private readonly channelName: string;
92
+ private readonly channel: Channel;
93
+
94
+ constructor(connection: Connection, channelName: string, channel: Channel) {
95
+ this.connection = connection;
96
+ this.channelName = channelName;
97
+ this.channel = channel;
98
+ }
99
+
100
+ /**
101
+ * Register a listener for presence events. Implicitly attaches the
102
+ * underlying channel — presence events arrive on the same WebSocket
103
+ * subscription as message frames.
104
+ */
105
+ subscribe(listener: PresenceEventListener): UnsubscribeFn {
106
+ const listeners = this.connection.addChannelListeners(this.channelName);
107
+ listeners.presence.add(listener);
108
+ this.channel.attach().catch(() => {});
109
+ return () => {
110
+ listeners.presence.delete(listener);
111
+ };
112
+ }
113
+
114
+ /** Announce this connection as present in the channel. */
115
+ async enter(data?: unknown): Promise<void> {
116
+ await this.send('enter', data);
117
+ }
118
+
119
+ /** Update the data attached to this connection's presence entry. */
120
+ async update(data?: unknown): Promise<void> {
121
+ await this.send('update', data);
122
+ }
123
+
124
+ /** Remove this connection's presence entry. */
125
+ async leave(): Promise<void> {
126
+ await this.send('leave', undefined);
127
+ }
128
+
129
+ private async send(action: PresenceAction, data: unknown): Promise<void> {
130
+ await this.channel.attach();
131
+ await this.connection.request({
132
+ t: 'pres',
133
+ channel: this.channelName,
134
+ action,
135
+ ...(data === undefined ? {} : { data }),
136
+ });
137
+ }
138
+ }