@pluv/io 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/PluvIO.ts ADDED
@@ -0,0 +1,322 @@
1
+ import type {
2
+ EventRecord,
3
+ InferIOAuthorize,
4
+ InferIOAuthorizeUser,
5
+ InferIOInput,
6
+ InferIOOutput,
7
+ IOAuthorize,
8
+ IOLike,
9
+ JsonObject,
10
+ Spread,
11
+ BaseIOAuthorize,
12
+ } from "@pluv/types";
13
+ import chalk from "chalk";
14
+ import type { AbstractPlatform } from "./AbstractPlatform";
15
+ import type { JWTEncodeParams } from "./authorize";
16
+ import { authorize } from "./authorize";
17
+ import { IORoom } from "./IORoom";
18
+ import type { EventConfig, InferEventConfig } from "./types";
19
+
20
+ export type InferIOContext<TIO extends PluvIO<any, any, any, any, any>> =
21
+ TIO extends PluvIO<any, any, infer IContext, any, any> ? IContext : never;
22
+
23
+ export type InferIOPlatform<TIO extends PluvIO<any, any, any, any>> =
24
+ TIO extends PluvIO<infer IPlatform, any, any, any, any> ? IPlatform : never;
25
+
26
+ export type InferIORoom<TIO extends PluvIO<any, any, any, any>> =
27
+ TIO extends PluvIO<
28
+ infer IPlatform,
29
+ infer IAuthorize,
30
+ infer IContext,
31
+ infer IInput,
32
+ infer IOutput
33
+ >
34
+ ? IORoom<IPlatform, IAuthorize, IContext, IInput, IOutput>
35
+ : never;
36
+
37
+ export interface PluvIORegisterOptions {
38
+ token?: string;
39
+ }
40
+
41
+ export interface PluvIOConfig<
42
+ TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
43
+ TAuthorize extends IOAuthorize<any, any> | null = null,
44
+ TContext extends JsonObject = {},
45
+ TInput extends EventRecord<string, any> = {},
46
+ TOutput extends EventRecord<string, any> = {}
47
+ > {
48
+ authorize?: TAuthorize;
49
+ context?: TContext;
50
+ debug?: boolean;
51
+ events?: InferEventConfig<TContext, TInput, TOutput>;
52
+ platform: TPlatform;
53
+ room?: string;
54
+ }
55
+
56
+ export class PluvIO<
57
+ TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
58
+ TAuthorize extends IOAuthorize<any, any> = BaseIOAuthorize,
59
+ TContext extends JsonObject = {},
60
+ TInput extends EventRecord<string, any> = {},
61
+ TOutput extends EventRecord<string, any> = {}
62
+ > implements IOLike<TAuthorize, TInput, TOutput>
63
+ {
64
+ readonly _authorize: TAuthorize | null = null;
65
+ readonly _debug: boolean;
66
+ readonly _events: InferEventConfig<TContext, TInput, TOutput> = {
67
+ $PING: {
68
+ resolver: (data, { session }) => {
69
+ if (!session) return {};
70
+
71
+ const currentTime = new Date().getTime();
72
+
73
+ session.timers.ping = currentTime;
74
+
75
+ return { $PONG: {} };
76
+ },
77
+ options: { type: "self" },
78
+ },
79
+ $GET_OTHERS: {
80
+ resolver: (data, { session, sessions }) => {
81
+ const others = Array.from(sessions.entries())
82
+ .filter(([, wsSession]) => wsSession.id !== session?.id)
83
+ .reduce<{
84
+ [connectionId: string]: {
85
+ connectionId: string;
86
+ room: string | null;
87
+ user: InferIOAuthorizeUser<TAuthorize>;
88
+ };
89
+ }>(
90
+ (acc, [connectionId, { presence, user }]) => ({
91
+ ...acc,
92
+ [connectionId]: {
93
+ connectionId,
94
+ presence,
95
+ room: this.room,
96
+ user: user as InferIOAuthorizeUser<TAuthorize>,
97
+ },
98
+ }),
99
+ {}
100
+ );
101
+
102
+ return { $OTHERS_RECEIVED: { others } };
103
+ },
104
+ options: { type: "sync" },
105
+ },
106
+ $UPDATE_PRESENCE: {
107
+ resolver: ({ presence }, { session }) => {
108
+ if (!session) return {};
109
+
110
+ session.presence = Object.assign(
111
+ Object.create(null),
112
+ session.presence,
113
+ presence
114
+ );
115
+
116
+ return { $PRESENCE_UPDATED: { presence } };
117
+ },
118
+ },
119
+ $UPDATE_STORAGE: {
120
+ resolver: ({ origin, update }, { doc, room }) => {
121
+ if (
122
+ origin === "$STORAGE_RECEIVED" &&
123
+ Object.keys(doc.toJSON()).length
124
+ ) {
125
+ return;
126
+ }
127
+
128
+ const updated = doc.applyUpdate(update);
129
+ const state = updated.encodeStateAsUpdate();
130
+
131
+ this._platform.persistance.setStorageState(room, state);
132
+
133
+ return { $STORAGE_UPDATED: { state } };
134
+ },
135
+ },
136
+ } as InferEventConfig<TContext, TInput, TOutput>;
137
+ readonly _platform: TPlatform;
138
+
139
+ readonly room: string | null = null;
140
+
141
+ private _context: TContext = {} as TContext;
142
+ private _rooms = new Map<
143
+ string,
144
+ IORoom<TPlatform, TAuthorize, TContext, TInput, TOutput>
145
+ >();
146
+
147
+ constructor(
148
+ options: PluvIOConfig<TPlatform, TAuthorize, TContext, TInput, TOutput>
149
+ ) {
150
+ const {
151
+ authorize,
152
+ context,
153
+ debug = false,
154
+ events,
155
+ platform,
156
+ room,
157
+ } = options;
158
+
159
+ this._debug = debug;
160
+ this._platform = platform;
161
+
162
+ if (authorize) this._authorize = authorize;
163
+ if (context) this._context = context;
164
+ if (events) this._events = events;
165
+ if (room) this.room = room;
166
+ }
167
+
168
+ public async createToken(
169
+ params: JWTEncodeParams<InferIOAuthorizeUser<InferIOAuthorize<this>>>
170
+ ): Promise<string> {
171
+ if (!this._authorize) {
172
+ throw new Error(
173
+ "IO does not specify authorize during initialization."
174
+ );
175
+ }
176
+
177
+ return await authorize({
178
+ platform: this._platform,
179
+ secret: this._authorize.secret,
180
+ }).encode(params);
181
+ }
182
+
183
+ public event<
184
+ TEvent extends string,
185
+ TData extends JsonObject = {},
186
+ TResult extends EventRecord<string, any> = {}
187
+ >(
188
+ event: TEvent,
189
+ config: EventConfig<TContext, TData, TResult>
190
+ ): PluvIO<
191
+ TPlatform,
192
+ TAuthorize,
193
+ TContext,
194
+ Spread<[TInput, EventRecord<TEvent, TData>]>,
195
+ Spread<[TOutput, TResult]>
196
+ > {
197
+ if (event.startsWith("$")) {
198
+ throw new Error('Event name must not start with "$".');
199
+ }
200
+
201
+ const newEvent = {
202
+ [event]: config,
203
+ } as InferEventConfig<
204
+ TContext,
205
+ EventRecord<TEvent, TData>,
206
+ TResult extends EventRecord<string, any> ? TResult : {}
207
+ >;
208
+
209
+ /**
210
+ * !HACK
211
+ * @description Typing this is becoming way too unmanagable, due to resolver
212
+ * being able to return void and needing to handle the conditional typing.
213
+ * Assume the types will work out, and cast as any here.
214
+ *
215
+ * TODO
216
+ * @description Revisit EventResolver and related types to resolve types
217
+ * without casting to any.
218
+ *
219
+ * @author leedavidcs
220
+ * @date September 25, 2022
221
+ */
222
+ return PluvIO.merge(
223
+ this as any,
224
+ new PluvIO({
225
+ events: newEvent as any,
226
+ platform: this._platform,
227
+ })
228
+ ) as any;
229
+ }
230
+
231
+ public getRoom(
232
+ room: string
233
+ ): IORoom<TPlatform, TAuthorize, TContext, TInput, TOutput> {
234
+ if (this.room) {
235
+ throw new Error("IO cannot getRoom from within a room.");
236
+ }
237
+
238
+ if (!/^[a-z0-9]+[a-z0-9\-_]+[a-z0-9]+$/i.test(room)) {
239
+ throw new Error("Unsupported room name");
240
+ }
241
+
242
+ const platform = this._platform;
243
+ const oldRoom = this._rooms.get(room);
244
+
245
+ if (oldRoom) return oldRoom;
246
+
247
+ const newRoom = new IORoom<
248
+ TPlatform,
249
+ TAuthorize,
250
+ TContext,
251
+ TInput,
252
+ TOutput
253
+ >(room, {
254
+ authorize: this._authorize ?? undefined,
255
+ context: this._context,
256
+ debug: this._debug,
257
+ events: this._events,
258
+ onDestroy: () => {
259
+ this._logDebug(`${chalk.blue("Deleting empty room:")} ${room}`);
260
+
261
+ this._rooms.delete(room);
262
+
263
+ if (this._debug) {
264
+ const rooms = Array.from(this._rooms.keys());
265
+
266
+ this._logDebug(`${chalk.blue("Deleted room:")} ${room}`);
267
+ this._logDebug(
268
+ `${chalk.blue("Rooms available:")} ${rooms.join(", ")}`
269
+ );
270
+ }
271
+ },
272
+ platform,
273
+ });
274
+
275
+ this._rooms.set(room, newRoom);
276
+
277
+ if (this._debug) {
278
+ const rooms = Array.from(this._rooms.keys());
279
+
280
+ this._logDebug(`${chalk.blue("Created room:")} ${room}`);
281
+ this._logDebug(
282
+ `${chalk.blue("Rooms available:")} ${rooms.join(", ")}`
283
+ );
284
+ }
285
+
286
+ return newRoom;
287
+ }
288
+
289
+ public static merge<TIO1 extends PluvIO, TIO2 extends PluvIO>(
290
+ first: TIO1,
291
+ second: TIO2
292
+ ): PluvIO<
293
+ InferIOPlatform<TIO1>,
294
+ InferIOAuthorize<TIO1>,
295
+ InferIOContext<TIO1>,
296
+ Spread<[InferIOInput<TIO1>, InferIOInput<TIO2>]>,
297
+ Spread<[InferIOOutput<TIO1>, InferIOOutput<TIO2>]>
298
+ > {
299
+ const authorize = (first._authorize ??
300
+ undefined) as InferIOAuthorize<TIO1>;
301
+ const debug = first._debug;
302
+ const platform = first._platform as InferIOPlatform<TIO1>;
303
+ const room = first.room ?? undefined;
304
+
305
+ const events1 = first._events;
306
+ const events2 = second._events;
307
+
308
+ const events = Object.assign(Object.create(null), events1, events2);
309
+
310
+ return new PluvIO({
311
+ authorize,
312
+ debug,
313
+ events,
314
+ platform,
315
+ room,
316
+ });
317
+ }
318
+
319
+ private _logDebug(...data: any[]): void {
320
+ this._debug && console.log(...data);
321
+ }
322
+ }
package/src/PubSub.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { Subject } from "wonka";
2
+ import { makeSubject, subscribe } from "wonka";
3
+ import type { IOPubSubEventMessage } from "./AbstractPubSub";
4
+ import { AbstractPubSub } from "./AbstractPubSub";
5
+
6
+ export class PubSub extends AbstractPubSub {
7
+ private _currentSubscriptionId: number = 0;
8
+ // Map of roomName to subjects
9
+ private _subjects = new Map<string, Subject<IOPubSubEventMessage<any>>>();
10
+ // Map of roomName to subscription ids
11
+ private _subscriptionsRefs = new Map<string, readonly number[]>();
12
+ // Map of
13
+ private _unsubscriptions = new Map<
14
+ number,
15
+ [roomName: string, unsubscribe: () => void]
16
+ >();
17
+
18
+ public async publish(
19
+ roomName: string,
20
+ payload: IOPubSubEventMessage<any>
21
+ ): Promise<void> {
22
+ const subject = this._subjects.get(roomName);
23
+
24
+ if (!subject) return;
25
+
26
+ Promise.resolve(subject.next(payload));
27
+ }
28
+
29
+ public subscribe(
30
+ roomName: string,
31
+ onMessage: (message: IOPubSubEventMessage<any>) => void
32
+ ): Promise<number> {
33
+ const id = this._currentSubscriptionId++;
34
+ const refs = this._subscriptionsRefs.get(roomName) ?? [];
35
+
36
+ const subject = makeSubject<IOPubSubEventMessage<any>>();
37
+
38
+ this._subjects.set(roomName, subject);
39
+ this._subscriptionsRefs.set(roomName, [...refs, id]);
40
+
41
+ const unsubscribe = subscribe(onMessage)(subject.source).unsubscribe;
42
+
43
+ this._unsubscriptions.set(id, [roomName, unsubscribe]);
44
+
45
+ return Promise.resolve(id);
46
+ }
47
+
48
+ public unsubscribe(subscriptionId: number): void {
49
+ const [roomName, unsubscribe] =
50
+ this._unsubscriptions.get(subscriptionId) ?? [];
51
+
52
+ if (!roomName || !unsubscribe) return;
53
+
54
+ const refs = this._subscriptionsRefs.get(roomName);
55
+
56
+ if (!refs) return;
57
+
58
+ if (refs.length === 1) {
59
+ this._subjects.delete(roomName);
60
+ this._subscriptionsRefs.delete(roomName);
61
+ this._unsubscriptions.delete(subscriptionId);
62
+
63
+ return;
64
+ }
65
+
66
+ const index = refs.indexOf(subscriptionId);
67
+
68
+ this._subscriptionsRefs.set(
69
+ roomName,
70
+ index === -1
71
+ ? refs
72
+ : [...refs.slice(0, index), ...refs.slice(index + 1)]
73
+ );
74
+
75
+ unsubscribe();
76
+
77
+ this._unsubscriptions.delete(subscriptionId);
78
+ }
79
+ }
@@ -0,0 +1,81 @@
1
+ import hkdf from "@panva/hkdf";
2
+ import { BaseUser } from "@pluv/types";
3
+ import { EncryptJWT, jwtDecrypt } from "jose";
4
+ import { AbstractPlatform } from "./AbstractPlatform";
5
+
6
+ const DEFAULT_MAX_AGE_MS = 60_000;
7
+
8
+ export const getEncryptionKey = async (secret: string): Promise<Uint8Array> => {
9
+ return await hkdf(
10
+ "sha256",
11
+ secret,
12
+ "",
13
+ "Pluv.io Generated Encryption Key",
14
+ 32
15
+ );
16
+ };
17
+
18
+ export interface JWT<TUser extends BaseUser> {
19
+ room: string;
20
+ sub: string;
21
+ user: TUser;
22
+ }
23
+
24
+ export interface JWTEncodeParams<TUser extends BaseUser> {
25
+ maxAge?: number;
26
+ room: string;
27
+ user: TUser;
28
+ }
29
+
30
+ export interface AuthorizeParams {
31
+ platform: AbstractPlatform;
32
+ secret: string;
33
+ }
34
+
35
+ export interface AuthorizeModule {
36
+ decode: <TUser extends BaseUser>(jwt: string) => Promise<TUser | null>;
37
+ encode: <TUser extends BaseUser>(
38
+ params: JWTEncodeParams<TUser>
39
+ ) => Promise<string>;
40
+ }
41
+
42
+ const now = () => (Date.now() / 1_000) | 0;
43
+
44
+ export const authorize = (params: AuthorizeParams) => {
45
+ const { platform, secret } = params;
46
+
47
+ const encode = async <TUser extends BaseUser>(
48
+ encodeParams: JWTEncodeParams<TUser>
49
+ ): Promise<string> => {
50
+ const { maxAge = DEFAULT_MAX_AGE_MS, room, user } = encodeParams;
51
+
52
+ const encryptionSecret = await getEncryptionKey(secret);
53
+
54
+ const token = await new EncryptJWT({
55
+ room,
56
+ sub: room ? `${room}|${user.id}` : user.id,
57
+ user,
58
+ })
59
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
60
+ .setIssuedAt()
61
+ .setExpirationTime(now() + maxAge)
62
+ .setJti(platform.randomUUID())
63
+ .encrypt(encryptionSecret);
64
+
65
+ return token.toString();
66
+ };
67
+
68
+ const decode = async <TUser extends BaseUser>(
69
+ jwt: string
70
+ ): Promise<JWT<TUser> | null> => {
71
+ const encryptionSecret = await getEncryptionKey(secret);
72
+
73
+ const { payload } = await jwtDecrypt(jwt, encryptionSecret, {
74
+ clockTolerance: 15,
75
+ });
76
+
77
+ return (payload as any) ?? null;
78
+ };
79
+
80
+ return { decode, encode };
81
+ };
@@ -0,0 +1,51 @@
1
+ import type {
2
+ BaseClientEventRecord,
3
+ BaseIOEventRecord,
4
+ BaseUser,
5
+ IOAuthorize,
6
+ JsonObject,
7
+ } from "@pluv/types";
8
+ import type { AbstractPlatform } from "./AbstractPlatform";
9
+ import { PluvIO } from "./PluvIO";
10
+
11
+ export interface CreateIOParams<
12
+ TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
13
+ TContext extends JsonObject = {},
14
+ TAuthorizeUser extends BaseUser = BaseUser,
15
+ TAuthorizeRequired extends boolean = false
16
+ > {
17
+ authorize?: IOAuthorize<TAuthorizeUser, TAuthorizeRequired>;
18
+ context?: TContext;
19
+ debug?: boolean;
20
+ platform: TPlatform;
21
+ }
22
+
23
+ export const createIO = <
24
+ TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
25
+ TContext extends JsonObject = {},
26
+ TAuthorizeUser extends BaseUser = BaseUser,
27
+ TAuthorizeRequired extends boolean = false
28
+ >(
29
+ params: CreateIOParams<
30
+ TPlatform,
31
+ TContext,
32
+ TAuthorizeUser,
33
+ TAuthorizeRequired
34
+ >
35
+ ): PluvIO<
36
+ TPlatform,
37
+ IOAuthorize<TAuthorizeUser, TAuthorizeRequired>,
38
+ TContext,
39
+ BaseClientEventRecord,
40
+ BaseIOEventRecord<IOAuthorize<TAuthorizeUser, TAuthorizeRequired>>
41
+ > => {
42
+ const { authorize, context, debug, platform } = params;
43
+
44
+ return new PluvIO<
45
+ TPlatform,
46
+ IOAuthorize<TAuthorizeUser, TAuthorizeRequired>,
47
+ TContext,
48
+ BaseClientEventRecord,
49
+ BaseIOEventRecord<IOAuthorize<TAuthorizeUser, TAuthorizeRequired>>
50
+ >({ authorize, context, debug, platform });
51
+ };
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ export type {
2
+ BaseUser,
3
+ EventMessage,
4
+ EventRecord,
5
+ IOEventMessage,
6
+ } from "@pluv/types";
7
+ export type {
8
+ ConvertWebSocketConfig,
9
+ InferWebSocketType,
10
+ } from "./AbstractPlatform";
11
+ export { AbstractPersistance } from "./AbstractPersistance";
12
+ export { AbstractPlatform } from "./AbstractPlatform";
13
+ export type { IOPubSubEventMessage } from "./AbstractPubSub";
14
+ export { AbstractPubSub } from "./AbstractPubSub";
15
+ export type {
16
+ AbstractCloseEvent,
17
+ AbstractErrorEvent,
18
+ AbstractEvent,
19
+ AbstractMessageEvent,
20
+ AbstractEventMap,
21
+ AbstractListener,
22
+ AbstractWebSocketConfig,
23
+ AbstractWebSocketHandleErrorParams,
24
+ } from "./AbstractWebSocket";
25
+ export { AbstractWebSocket } from "./AbstractWebSocket";
26
+ export type {
27
+ AuthorizeModule,
28
+ AuthorizeParams,
29
+ JWT,
30
+ JWTEncodeParams,
31
+ } from "./authorize";
32
+ export { createIO } from "./createIO";
33
+ export type { IORoom } from "./IORoom";
34
+ export type { InferIORoom, PluvIO } from "./PluvIO";
package/src/types.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { YjsDoc } from "@pluv/crdt-yjs";
2
+ import type {
3
+ EventRecord,
4
+ Id,
5
+ InputZodLike,
6
+ JsonObject,
7
+ MaybePromise,
8
+ } from "@pluv/types";
9
+ import type { AbstractWebSocket } from "./AbstractWebSocket";
10
+
11
+ export interface Console {
12
+ debug(...data: any[]): void;
13
+ error(...data: any[]): void;
14
+ info(...data: any[]): void;
15
+ log(...data: any[]): void;
16
+ warn(...data: any[]): void;
17
+ }
18
+
19
+ declare global {
20
+ var console: {
21
+ log: (...data: any[]) => void;
22
+ };
23
+ }
24
+
25
+ export interface EventConfig<
26
+ TContext extends JsonObject = {},
27
+ TData extends JsonObject = {},
28
+ TResult extends EventRecord<string, any> = {}
29
+ > {
30
+ input?: InputZodLike<TData>;
31
+ options?: SendMessageOptions;
32
+ resolver: EventResolver<TContext, TData, TResult>;
33
+ }
34
+
35
+ export type EventConfigType = "broadcast" | "self" | "sync";
36
+
37
+ export type EventResolver<
38
+ TContext extends JsonObject = {},
39
+ TData extends JsonObject = {},
40
+ TResult extends EventRecord<string, any> = {}
41
+ > = (
42
+ data: TData,
43
+ context: EventResolverContext<TContext>
44
+ ) => MaybePromise<TResult | void>;
45
+
46
+ export interface EventResolverContext<TContext extends JsonObject = {}> {
47
+ context: TContext;
48
+ doc: YjsDoc<any>;
49
+ room: string;
50
+ /**
51
+ * !HACK
52
+ * @description Session should only not exist when invoking the event as a
53
+ * "sync" event on another machine where the session doesn't exist. There must
54
+ * be a better way to handle this, but going to make this nullable for ease
55
+ * for now.
56
+ *
57
+ * `session` should not be referenced most of the time anyhow (outside of
58
+ * internal to this library).
59
+ * @author leedavidcs
60
+ * @date December 25, 2022
61
+ */
62
+ session: WebSocketSession | null;
63
+ sessions: Map<string, WebSocketSession>;
64
+ }
65
+
66
+ export type InferEventConfig<
67
+ TContext extends JsonObject = {},
68
+ TInput extends EventRecord<string, any> = {},
69
+ TOutput extends EventRecord<string, any> = {}
70
+ > = {
71
+ [P in keyof TInput]: P extends string
72
+ ? Id<EventConfig<TContext, TInput[P], TOutput>>
73
+ : never;
74
+ };
75
+
76
+ export type SendMessageOptions =
77
+ | { type?: "broadcast"; sessionIds?: readonly string[] }
78
+ | { type: "self" }
79
+ | { type: "sync" };
80
+
81
+ export interface WebSocketSessionTimers {
82
+ ping: number;
83
+ }
84
+
85
+ export interface WebSocketSession {
86
+ id: string;
87
+ presence: JsonObject | null;
88
+ quit: boolean;
89
+ room: string;
90
+ timers: WebSocketSessionTimers;
91
+ webSocket: AbstractWebSocket;
92
+ user: JsonObject | null;
93
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "@pluv/tsconfig/library.json",
3
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules"]
5
+ }