@pluv/platform-pluv 0.0.0-experimental-20250527040415

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.
@@ -0,0 +1,329 @@
1
+ import type {
2
+ AbstractPlatformConfig,
3
+ AbstractWebSocket,
4
+ BaseUser,
5
+ ConvertWebSocketConfig,
6
+ GetInitialStorageFn,
7
+ JWTEncodeParams,
8
+ PluvContext,
9
+ WebSocketSerializedState,
10
+ } from "@pluv/io";
11
+ import { AbstractPlatform } from "@pluv/io";
12
+ import type { MaybePromise } from "@pluv/types";
13
+ import stringify from "fast-json-stable-stringify";
14
+ import type { Context } from "hono";
15
+ import { Hono } from "hono";
16
+ import type { BlankEnv, BlankInput } from "hono/types";
17
+ import { SIGNATURE_ALGORITHM, SIGNATURE_HEADER } from "./constants";
18
+ import { ZodEvent } from "./schemas";
19
+ import { createErrorResponse, createSuccessResponse, HttpError, verifyWebhook } from "./shared";
20
+ import type { PluvIOEndpoints, PluvIOListeners } from "./types";
21
+
22
+ export type PublicKey = string | (() => MaybePromise<string>);
23
+ export type SecretKey = string | (() => MaybePromise<string>);
24
+ export type WebhookSecret = string | (() => MaybePromise<string>);
25
+
26
+ export interface PluvPlatformConfig<TContext extends Record<string, any> = {}> {
27
+ /**
28
+ * @ignore
29
+ * @readonly
30
+ * @deprecated Internal use only. Changes to this will never be marked as breaking.
31
+ */
32
+ _defs?: {
33
+ debug?: boolean;
34
+ endpoints?: PluvIOEndpoints | (() => MaybePromise<PluvIOEndpoints>);
35
+ };
36
+ basePath: string;
37
+ context?: PluvContext<any, TContext>;
38
+ publicKey: PublicKey;
39
+ secretKey: SecretKey;
40
+ webhookSecret?: WebhookSecret;
41
+ }
42
+
43
+ export class PluvPlatform<
44
+ TContext extends Record<string, any> = {},
45
+ TUser extends BaseUser = BaseUser,
46
+ > extends AbstractPlatform<
47
+ any,
48
+ {},
49
+ {},
50
+ {
51
+ authorize: {
52
+ secret: false;
53
+ };
54
+ handleMode: "fetch";
55
+ registrationMode: "attached";
56
+ requireAuth: true;
57
+ listeners: {
58
+ onRoomDeleted: true;
59
+ onRoomMessage: false;
60
+ onStorageUpdated: false;
61
+ onUserConnected: true;
62
+ onUserDisconnected: true;
63
+ };
64
+ router: false;
65
+ }
66
+ > {
67
+ public readonly id = Math.random().toString();
68
+ public readonly _config = {
69
+ authorize: {
70
+ secret: false as const,
71
+ },
72
+ handleMode: "fetch" as const,
73
+ registrationMode: "attached" as const,
74
+ requireAuth: true as const,
75
+ listeners: {
76
+ onRoomDeleted: true as const,
77
+ onRoomMessage: false as const,
78
+ onStorageUpdated: false as const,
79
+ onUserConnected: true as const,
80
+ onUserDisconnected: true as const,
81
+ },
82
+ router: false as const,
83
+ };
84
+ public readonly _name = "platformPluv";
85
+
86
+ private readonly _app: Hono;
87
+ private readonly _basePath: string;
88
+ private readonly _context: PluvContext<this, TContext>;
89
+ private readonly _debug: boolean;
90
+ private readonly _endpoints: PluvIOEndpoints | (() => MaybePromise<PluvIOEndpoints>);
91
+ private _getInitialStorage?: GetInitialStorageFn<{}>;
92
+ private _listeners?: PluvIOListeners;
93
+ private readonly _publicKey: PublicKey;
94
+ private readonly _secretKey: SecretKey;
95
+ private readonly _webhookSecret?: WebhookSecret;
96
+
97
+ public _createToken = async (params: JWTEncodeParams<any, any>): Promise<string> => {
98
+ const parsed = params.authorize.user.parse(params.user);
99
+
100
+ const [endpoints, publicKey, secretKey] = await Promise.all([
101
+ typeof this._endpoints === "object" ? this._endpoints : this._endpoints(),
102
+ typeof this._publicKey === "string" ? this._publicKey : this._publicKey(),
103
+ typeof this._secretKey === "string" ? this._secretKey : this._secretKey(),
104
+ ]);
105
+
106
+ this._logDebug({ endpoints, publicKey, secretKey });
107
+
108
+ const res = await fetch(endpoints.createToken, {
109
+ headers: { "content-type": "application/json" },
110
+ method: "post",
111
+ body: JSON.stringify({
112
+ maxAge: params.maxAge ?? null,
113
+ publicKey,
114
+ room: params.room,
115
+ secretKey,
116
+ user: parsed,
117
+ }),
118
+ }).catch((error) => {
119
+ this._logDebug(error);
120
+
121
+ return null;
122
+ });
123
+
124
+ this._logDebug({ response: { status: res?.status ?? null } });
125
+
126
+ if (!res || !res.ok || res.status !== 200) {
127
+ throw new Error("Authorization failed");
128
+ }
129
+
130
+ const token = await res.text().catch(() => null);
131
+
132
+ this._logDebug({ token });
133
+
134
+ if (typeof token !== "string") throw new Error("Authorization failed");
135
+
136
+ return token;
137
+ };
138
+
139
+ constructor(params: PluvPlatformConfig) {
140
+ super();
141
+
142
+ const { _defs, basePath, context, publicKey, secretKey, webhookSecret } = params;
143
+
144
+ this._basePath = basePath;
145
+ this._context = (context ?? {}) as TContext;
146
+ this._debug = _defs?.debug ?? false;
147
+ this._endpoints = _defs?.endpoints ?? {
148
+ createToken: "https://rooms.pluv.io/api/room/token",
149
+ };
150
+ this._publicKey = publicKey;
151
+ this._secretKey = secretKey;
152
+ this._webhookSecret = webhookSecret;
153
+
154
+ this._app = new Hono().basePath(this._basePath).route("/", this._webhooksRouter);
155
+ this._fetch = this._app.fetch as (req: any) => Promise<any>;
156
+ }
157
+
158
+ public acceptWebSocket(webSocket: AbstractWebSocket): Promise<void> {
159
+ throw new Error("Not implemented");
160
+ }
161
+
162
+ public convertWebSocket(webSocket: any, config: ConvertWebSocketConfig): AbstractWebSocket {
163
+ throw new Error("Not implemented");
164
+ }
165
+
166
+ public getLastPing(webSocket: AbstractWebSocket): number | null {
167
+ throw new Error("Not implemented");
168
+ }
169
+
170
+ public getSerializedState(webSocket: any): WebSocketSerializedState | null {
171
+ throw new Error("Not implemented");
172
+ }
173
+
174
+ public getSessionId(webSocket: any): string | null {
175
+ throw new Error("Not implemented");
176
+ }
177
+
178
+ public getWebSockets(): readonly any[] {
179
+ throw new Error("Not implemented");
180
+ }
181
+
182
+ public initialize(config: AbstractPlatformConfig<{}>): this {
183
+ throw new Error("Not implemented");
184
+ }
185
+
186
+ public parseData(data: string | ArrayBuffer): Record<string, any> {
187
+ throw new Error("Not implemented");
188
+ }
189
+
190
+ public randomUUID(): string {
191
+ throw new Error("Not implemented");
192
+ }
193
+
194
+ public setSerializedState(
195
+ webSocket: AbstractWebSocket,
196
+ state: WebSocketSerializedState,
197
+ ): WebSocketSerializedState {
198
+ throw new Error("Not implemented");
199
+ }
200
+
201
+ public validateConfig(config: any): void {
202
+ if (!config.authorize) {
203
+ throw new Error("Config `authorize` must be provided to `platformPluv`");
204
+ }
205
+ if (!!config.onRoomMessage) {
206
+ throw new Error("Config `onRoomMessage` is not supported on `platformPluv`");
207
+ }
208
+ if (!!config.onStorageUpdated) {
209
+ throw new Error("Config `onStorageUpdated` is not supported on `platformPluv`");
210
+ }
211
+
212
+ this._getInitialStorage = config.getInitialStorage;
213
+ this._listeners = {
214
+ onRoomDeleted: (event) => config.onRoomDeleted?.(event),
215
+ onUserConnected: (event) => config.onUserConnected?.(event),
216
+ onUserDisconnected: (event) => config.onUserDisconnected?.(event),
217
+ };
218
+ }
219
+
220
+ private _webhooksRouter = new Hono()
221
+ .basePath("/")
222
+ .post("/", async (c: Context<BlankEnv, "/", BlankInput>) => {
223
+ const [algorithm, signature] = c.req.header(SIGNATURE_HEADER)?.split("=") ?? [];
224
+
225
+ try {
226
+ if (!this._webhookSecret) throw new HttpError("Unauthorized", 401);
227
+ if (algorithm !== SIGNATURE_ALGORITHM) throw new HttpError("Unauthorized", 401);
228
+ if (!signature) throw new HttpError("Unauthorized", 401);
229
+
230
+ const [payload, webhookSecret] = await Promise.all([
231
+ c.req.json(),
232
+ typeof this._webhookSecret === "string"
233
+ ? this._webhookSecret
234
+ : await this._webhookSecret(),
235
+ ]);
236
+
237
+ const verified = await verifyWebhook({
238
+ payload: stringify(payload),
239
+ signature,
240
+ secret: webhookSecret,
241
+ });
242
+
243
+ if (!verified) throw new HttpError("Unauthorized", 401);
244
+
245
+ const parsed = ZodEvent.safeParse(payload);
246
+
247
+ if (!parsed.success) throw new HttpError("Invalid request", 400);
248
+
249
+ const { event, data } = parsed.data;
250
+ const context = this._getContext();
251
+
252
+ switch (event) {
253
+ case "initial-storage": {
254
+ const room = data.room;
255
+ const storage =
256
+ typeof room === "string"
257
+ ? ((await this._getInitialStorage?.({ context, room })) ?? null)
258
+ : null;
259
+
260
+ return createSuccessResponse(c, { event, room, storage });
261
+ }
262
+ case "room-deleted": {
263
+ const room = data.room;
264
+ const encodedState = data.storage;
265
+
266
+ await Promise.resolve(
267
+ this._listeners?.onRoomDeleted({ context, encodedState, room }),
268
+ );
269
+
270
+ return createSuccessResponse(c, { event, room });
271
+ }
272
+ case "user-connected": {
273
+ const room = data.room;
274
+ const encodedState = data.storage;
275
+ const user = data.user as any;
276
+
277
+ await Promise.resolve(
278
+ this._listeners?.onUserConnected({
279
+ context,
280
+ encodedState,
281
+ platform: this,
282
+ room,
283
+ user,
284
+ }),
285
+ );
286
+
287
+ return createSuccessResponse(c, { event, room });
288
+ }
289
+ case "user-disconnected": {
290
+ const room = data.room;
291
+ const encodedState = data.storage;
292
+ const user = data.user as any;
293
+
294
+ await Promise.resolve(
295
+ this._listeners?.onUserDisconnected({
296
+ context,
297
+ encodedState,
298
+ platform: this,
299
+ room,
300
+ user,
301
+ }),
302
+ );
303
+
304
+ return createSuccessResponse(c, { event, room });
305
+ }
306
+ default: {
307
+ throw new HttpError("Unknown event", 400);
308
+ }
309
+ }
310
+ } catch (error) {
311
+ const message = error instanceof Error ? error.message : "Unexpected error";
312
+ const status = error instanceof HttpError ? error.status : 500;
313
+
314
+ return createErrorResponse(c, { message }, status);
315
+ }
316
+ });
317
+
318
+ private _getContext(): TContext {
319
+ return typeof this._context === "function"
320
+ ? this._context(this._roomContext as any)
321
+ : this._context;
322
+ }
323
+
324
+ private _logDebug(...args: any[]): void {
325
+ if (!this._debug) return;
326
+
327
+ console.log("[PLATFORM PLUV]", ...args);
328
+ }
329
+ }
@@ -0,0 +1,2 @@
1
+ export const SIGNATURE_ALGORITHM = "sha256";
2
+ export const SIGNATURE_HEADER = "x-pluv-signature-256";
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { platformPluv } from "./platformPluv";
2
+ export type { PlatformPluvCreateIOParams } from "./platformPluv";
3
+ export { PluvPlatform } from "./PluvPlatform";
4
+ export type { PluvIOEndpoints } from "./types";
@@ -0,0 +1,35 @@
1
+ import type { CreateIOParams, InferInitContextType, PluvContext, PluvIOAuthorize } from "@pluv/io";
2
+ import type { BaseUser, Id } from "@pluv/types";
3
+ import type { PluvPlatformConfig } from "./PluvPlatform";
4
+ import { PluvPlatform } from "./PluvPlatform";
5
+
6
+ export type PlatformPluvCreateIOParams<
7
+ TContext extends Record<string, any> = {},
8
+ TUser extends BaseUser = BaseUser,
9
+ > = Id<
10
+ PluvPlatformConfig &
11
+ Omit<
12
+ CreateIOParams<PluvPlatform, TContext, TUser>,
13
+ "authorize" | "context" | "limits" | "platform"
14
+ > & {
15
+ authorize: PluvIOAuthorize<PluvPlatform, TUser, InferInitContextType<PluvPlatform>>;
16
+ context?: PluvContext<PluvPlatform, TContext>;
17
+ }
18
+ >;
19
+
20
+ export const platformPluv = <
21
+ TContext extends Record<string, any> = {},
22
+ TUser extends BaseUser = BaseUser,
23
+ >(
24
+ config: PlatformPluvCreateIOParams<TContext, TUser>,
25
+ ): CreateIOParams<PluvPlatform<TContext, TUser>, TContext, TUser> => {
26
+ const { authorize, context, crdt, debug } = config;
27
+
28
+ return {
29
+ authorize,
30
+ context,
31
+ crdt,
32
+ debug,
33
+ platform: () => new PluvPlatform<TContext, TUser>(config),
34
+ };
35
+ };
package/src/schemas.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { z } from "zod";
2
+
3
+ export const ZodEventKind = z.union([
4
+ z.literal("initial-storage"),
5
+ z.literal("room-deleted"),
6
+ z.literal("user-connected"),
7
+ z.literal("user-disconnected"),
8
+ ]);
9
+
10
+ export const ZodEventInitialStorage = z.object({
11
+ event: z.literal("initial-storage"),
12
+ data: z.object({
13
+ room: z.string(),
14
+ }),
15
+ });
16
+
17
+ export const ZodEventRoomDeleted = z.object({
18
+ event: z.literal("room-deleted"),
19
+ data: z.object({
20
+ room: z.string(),
21
+ storage: z.string().nullable(),
22
+ }),
23
+ });
24
+
25
+ export const ZodEventUserConnected = z.object({
26
+ event: z.literal("user-connected"),
27
+ data: z.object({
28
+ room: z.string(),
29
+ storage: z.string().nullable(),
30
+ user: z
31
+ .object({
32
+ id: z.string(),
33
+ })
34
+ .passthrough(),
35
+ }),
36
+ });
37
+
38
+ export const ZodEventUserDisconnected = z.object({
39
+ event: z.literal("user-disconnected"),
40
+ data: z.object({
41
+ room: z.string(),
42
+ storage: z.string().nullable(),
43
+ user: z
44
+ .object({
45
+ id: z.string(),
46
+ })
47
+ .passthrough(),
48
+ }),
49
+ });
50
+
51
+ export const ZodEvent = z.discriminatedUnion("event", [
52
+ ZodEventInitialStorage,
53
+ ZodEventRoomDeleted,
54
+ ZodEventUserConnected,
55
+ ZodEventUserDisconnected,
56
+ ]);
57
+
58
+ export const ZodInitialStorageResponse = z.object({
59
+ event: z.literal("initial-storage"),
60
+ room: z.string(),
61
+ storage: z.string().nullable(),
62
+ });
63
+
64
+ export const ZodRoomDeletedResponse = z.object({
65
+ event: z.literal("room-deleted"),
66
+ room: z.string(),
67
+ });
68
+
69
+ export const ZodUserConnectedResponse = z.object({
70
+ event: z.literal("user-connected"),
71
+ room: z.string(),
72
+ });
73
+
74
+ export const ZodUserDisconnectedResponse = z.object({
75
+ event: z.literal("user-disconnected"),
76
+ room: z.string(),
77
+ });
78
+
79
+ export const ZodEventResponse = z.discriminatedUnion("event", [
80
+ ZodInitialStorageResponse,
81
+ ZodRoomDeletedResponse,
82
+ ZodUserConnectedResponse,
83
+ ZodUserDisconnectedResponse,
84
+ ]);
@@ -0,0 +1,11 @@
1
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
2
+
3
+ export class HttpError extends Error {
4
+ readonly status: ContentfulStatusCode;
5
+
6
+ constructor(message: string, status: ContentfulStatusCode) {
7
+ super(message);
8
+
9
+ this.status = status;
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ import type { Context } from "hono";
2
+ import type { BlankEnv, BlankInput } from "hono/types";
3
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
4
+
5
+ export const createErrorResponse = <TStatus extends ContentfulStatusCode>(
6
+ c: Context<BlankEnv, "/", BlankInput>,
7
+ error: { message: string },
8
+ status: TStatus,
9
+ ) => {
10
+ return c.json({ ok: false, error: { message: error.message } }, status);
11
+ };
@@ -0,0 +1,41 @@
1
+ import type { webcrypto } from "crypto";
2
+ import { getCrypto } from "./getCrypto";
3
+
4
+ export interface CreateHmacParams {
5
+ payload: string;
6
+ secret: string;
7
+ }
8
+
9
+ export type CreateHmacResult = {
10
+ algorithm: "sha256";
11
+ hmac: string;
12
+ };
13
+
14
+ export const createHmac = async (params: CreateHmacParams): Promise<CreateHmacResult> => {
15
+ const { payload, secret } = params;
16
+
17
+ if (!payload || !secret) throw new Error("Secret and payload are required to sign payload");
18
+
19
+ const encoder = new TextEncoder();
20
+ const keyBytes = encoder.encode(secret);
21
+
22
+ const crypto = getCrypto();
23
+
24
+ const algorithm: webcrypto.HmacImportParams = { name: "HMAC", hash: { name: "SHA-256" } };
25
+ const extractable = false;
26
+
27
+ const key = await crypto.subtle.importKey("raw", keyBytes, algorithm, extractable, [
28
+ "sign",
29
+ "verify",
30
+ ]);
31
+ const payloadBytes = encoder.encode(payload);
32
+
33
+ const signature = await crypto.subtle.sign("HMAC", key, payloadBytes);
34
+
35
+ // Convert the signature to a hex string
36
+ const hmac = Array.from(new Uint8Array(signature))
37
+ .map((b) => ("0" + b.toString(16)).slice(-2))
38
+ .join("");
39
+
40
+ return { algorithm: "sha256", hmac };
41
+ };
@@ -0,0 +1,24 @@
1
+ import type { Context } from "hono";
2
+ import type { BlankEnv, BlankInput } from "hono/types";
3
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
4
+ import { ZodEventResponse } from "../schemas";
5
+ import type { EventResponse } from "../types";
6
+
7
+ export interface CreateSuccessResponseParams<TStatus extends ContentfulStatusCode> {
8
+ data: EventResponse;
9
+ status?: TStatus;
10
+ }
11
+
12
+ export const createSuccessResponse = <TStatus extends ContentfulStatusCode = 200>(
13
+ c: Context<BlankEnv, "/", BlankInput>,
14
+ data: EventResponse,
15
+ status: TStatus = 200 as TStatus,
16
+ ) => {
17
+ return c.json(
18
+ {
19
+ ok: true,
20
+ data: ZodEventResponse.parse(data),
21
+ },
22
+ status,
23
+ );
24
+ };
@@ -0,0 +1,17 @@
1
+ import type { webcrypto } from "crypto";
2
+
3
+ export const getCrypto = (): webcrypto.Crypto => {
4
+ if (typeof crypto !== "undefined") {
5
+ // In a browser or Web Worker (including Cloudflare Workers)
6
+ return crypto;
7
+ }
8
+
9
+ if (typeof require === "function") {
10
+ // In Node.js
11
+ // Node 15+ supports `crypto.webcrypto`
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ return require("node:crypto").webcrypto as webcrypto.Crypto;
14
+ }
15
+
16
+ throw new Error("Missing crypto module");
17
+ };
@@ -0,0 +1,13 @@
1
+ export { createErrorResponse } from "./createErrorResponse";
2
+ export type { CreateErrorResponseParams } from "./createErrorResponse";
3
+ export { createHmac } from "./createHmac";
4
+ export type { CreateHmacParams } from "./createHmac";
5
+ export { createSuccessResponse } from "./createSuccessResponse";
6
+ export type { CreateSuccessResponseParams } from "./createSuccessResponse";
7
+ export { getCrypto } from "./getCrypto";
8
+ export { HttpError } from "./HttpError";
9
+ export { signWebhook } from "./signWebhook";
10
+ export type { SignWebhookParams } from "./signWebhook";
11
+ export { timingSafeEqual } from "./timingSafeEqual";
12
+ export { verifyWebhook } from "./verifyWebhook";
13
+ export type { VerifyWebhookParams } from "./verifyWebhook";
@@ -0,0 +1,10 @@
1
+ import type { CreateHmacParams } from "./createHmac";
2
+ import { createHmac } from "./createHmac";
3
+
4
+ export type SignWebhookParams = CreateHmacParams;
5
+
6
+ export const signWebhook = async (params: SignWebhookParams): Promise<string> => {
7
+ const { algorithm, hmac } = await createHmac(params);
8
+
9
+ return `${algorithm}=${hmac}`;
10
+ };
@@ -0,0 +1,10 @@
1
+ export const timingSafeEqual = (a: Uint8Array, b: Uint8Array): boolean => {
2
+ if (a.length !== b.length) return false; // Lengths are different
3
+
4
+ let result = 0;
5
+ for (let i = 0; i < a.length; i++) {
6
+ result |= a[i] ^ b[i]; // XOR each byte
7
+ }
8
+
9
+ return result === 0; // If all bytes are equal, result will be 0
10
+ };
@@ -0,0 +1,28 @@
1
+ import { createHmac } from "./createHmac";
2
+ import { timingSafeEqual } from "./timingSafeEqual";
3
+
4
+ export interface VerifyWebhookParams {
5
+ payload: string;
6
+ secret: string;
7
+ signature: string;
8
+ }
9
+
10
+ export const verifyWebhook = async (params: VerifyWebhookParams): Promise<boolean> => {
11
+ const { payload, secret, signature } = params;
12
+
13
+ if (!secret || !payload || !signature) {
14
+ throw new Error("Secret, payload and signature are required to verify payload");
15
+ }
16
+
17
+ const { hmac } = await createHmac({ payload, secret });
18
+
19
+ if (hmac.length !== signature.length) return false;
20
+
21
+ const encoder = new TextEncoder();
22
+ const verificationBytes = encoder.encode(hmac);
23
+ const signatureBytes = encoder.encode(signature);
24
+
25
+ if (verificationBytes.length !== signatureBytes.length) return false;
26
+
27
+ return timingSafeEqual(verificationBytes, signatureBytes);
28
+ };