From 61f4151a127acf9428cf7e6dd2dada6dd1bc883d Mon Sep 17 00:00:00 2001 From: Mark Hughes Date: Tue, 6 Jan 2026 19:45:06 +1100 Subject: [PATCH] feat(#127): add foundation for Droppy SDK --- .gitignore | 7 +- package.json | 1 + packages/sdk/README.md | 3 + packages/sdk/package.json | 54 +++++ packages/sdk/scripts/build-client-min.mjs | 55 +++++ packages/sdk/src/client/index.ts | 32 +++ packages/sdk/src/client/transport/http.ts | 51 ++++ packages/sdk/src/client/transport/ws.ts | 119 +++++++++ packages/sdk/src/sdk/errors/error.ts | 14 ++ packages/sdk/src/sdk/errors/http-error.ts | 12 + packages/sdk/src/sdk/index.ts | 4 + packages/sdk/src/sdk/sdk.ts | 145 +++++++++++ packages/sdk/src/sdk/transport.ts | 28 +++ packages/sdk/src/server/index.ts | 27 +++ packages/sdk/src/server/transport/http.ts | 105 ++++++++ packages/sdk/src/server/transport/ws.ts | 133 +++++++++++ packages/sdk/tsconfig.base.json | 12 + packages/sdk/tsconfig.client.json | 7 + packages/sdk/tsconfig.json | 7 + packages/sdk/tsconfig.server.json | 4 + yarn.lock | 278 ++++++++++++++++++++++ 21 files changed, 1097 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/scripts/build-client-min.mjs create mode 100644 packages/sdk/src/client/index.ts create mode 100644 packages/sdk/src/client/transport/http.ts create mode 100644 packages/sdk/src/client/transport/ws.ts create mode 100644 packages/sdk/src/sdk/errors/error.ts create mode 100644 packages/sdk/src/sdk/errors/http-error.ts create mode 100644 packages/sdk/src/sdk/index.ts create mode 100644 packages/sdk/src/sdk/sdk.ts create mode 100644 packages/sdk/src/sdk/transport.ts create mode 100644 packages/sdk/src/server/index.ts create mode 100644 packages/sdk/src/server/transport/http.ts create mode 100644 packages/sdk/src/server/transport/ws.ts create mode 100644 packages/sdk/tsconfig.base.json create mode 100644 packages/sdk/tsconfig.client.json create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsconfig.server.json diff --git a/.gitignore b/.gitignore index 8a1b719c..bc45bf1c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,9 @@ coverage/ !.yarn/plugins !.yarn/releases !.yarn/sdks -!.yarn/versions \ No newline at end of file +!.yarn/versions + +packages/sdk/droppy.global.min.js +packages/sdk/droppy.global.min.js.map +packages/sdk/droppy.esm.min.js +packages/sdk/droppy.esm.min.js.map diff --git a/package.json b/package.json index 6d2f8812..8cc431c7 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/ws": "^8.18.1", "babel-jest": "^29.7.0", "clean-css": "5.1.5", + "esbuild": "^0.27.2", "globals": "^15.9.0", "html-minifier": "4.0.0", "husky": "^7.0.2", diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..2671fb72 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,3 @@ +# `@droppyjs/sdk` + +> SDK for droppy. Read more at https://github.com/droppyjs/droppy diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..1ef5065d --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,54 @@ +{ + "name": "@droppyjs/sdk", + "version": "2.0.0-alpha.3", + "description": "> SDK for droppy.", + "author": "droppyjs ", + "homepage": "https://github.com/droppyjs/droppy#readme", + "license": "BSD-2-Clause", + "type": "module", + "exports": { + "./shared": { + "import": "./dist/shared/index.js", + "types": "./dist/shared/index.d.ts" + }, + "./client": { + "import": "./dist/client/index.js", + "types": "./dist/client/index.d.ts" + }, + "./server": { + "import": "./dist/server/index.js", + "types": "./dist/server/index.d.ts" + }, + "./global.min": { + "import": "./droppy.global.min.js", + "types": "./droppy.global.min.d.ts" + }, + "./esm.min": { + "import": "./droppy.esm.min.js", + "types": "./droppy.esm.min.d.ts" + } + }, + "files": [ + "dist", + "droppy.global.min.js", + "droppy.esm.min.js", + "droppy.global.min.js.map", + "droppy.esm.min.js.map" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/droppyjs/droppy.git" + }, + "bugs": { + "url": "https://github.com/droppyjs/droppy/issues" + }, + "scripts": { + "build:ts": "node ../../node_modules/typescript/bin/tsc -p tsconfig.server.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.client.json", + "build:client:min": "node ./scripts/build-client-min.mjs", + "build": "yarn build:ts && yarn build:client:min", + "test": "echo \"Error: run tests from root\" && exit 1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sdk/scripts/build-client-min.mjs b/packages/sdk/scripts/build-client-min.mjs new file mode 100644 index 00000000..2dbe4237 --- /dev/null +++ b/packages/sdk/scripts/build-client-min.mjs @@ -0,0 +1,55 @@ +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sdkRoot = path.resolve(__dirname, ".."); + +const inputFile = path.join(sdkRoot, "dist", "client", "index.js"); +const outputIifeFile = path.join(sdkRoot, "droppy.global.min.js"); +const outputEsmFile = path.join(sdkRoot, "droppy.esm.min.js"); + +try { + const header = `// .:. +// ::: .:::::. Droppy +// ..:::.. ::: Made with love <3 +// ':::' ::: +// ' +// For more information, see https://github.com/droppyjs/droppy +`; + await build({ + entryPoints: [inputFile], + outfile: outputIifeFile, + bundle: true, + minify: true, + sourcemap: true, + platform: "browser", + format: "iife", + globalName: "DroppySDK", + target: ["es2020"], + logLevel: "silent", + banner: { + js: header, + }, + }); + await build({ + entryPoints: [inputFile], + outfile: outputEsmFile, + bundle: true, + minify: true, + sourcemap: true, + platform: "browser", + format: "esm", + target: ["es2020"], + logLevel: "silent", + banner: { + js: header, + }, + }); +} catch (err) { + console.error(`Failed to generate web bundles from ${inputFile}`); + console.error(err); + process.exitCode = 1; +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts new file mode 100644 index 00000000..f68af8f3 --- /dev/null +++ b/packages/sdk/src/client/index.ts @@ -0,0 +1,32 @@ +import { DroppySDK } from "../sdk/sdk.js"; +import { DroppyClientHttpTransport } from "./transport/http.js"; +import { DroppyClientWsTransport } from "./transport/ws.js"; + +export class DroppyClientSDK extends DroppySDK { + getRootPath() { + const p = window.location.pathname; + if (p[p.length - 1]) { + return p; + } else { + return `${p}/`; + } + } + + _onConstruct(): void { + if (!this.hostname) { + this.hostname = window.location.origin; + } + this.verifyHostname(); + } + + _getTransport() { + return { + http: new DroppyClientHttpTransport(), + ws: new DroppyClientWsTransport(), + }; + } +} + +export * from "../sdk/index.js"; +export * from "./transport/http.js"; +export * from "./transport/ws.js"; diff --git a/packages/sdk/src/client/transport/http.ts b/packages/sdk/src/client/transport/http.ts new file mode 100644 index 00000000..3a26adf9 --- /dev/null +++ b/packages/sdk/src/client/transport/http.ts @@ -0,0 +1,51 @@ +import type { DroppyHttpTransport, HttpMethod } from "../../sdk/transport.js"; + +export class DroppyClientHttpTransport implements DroppyHttpTransport { + async request(req: { + method: HttpMethod; + path: string; + headers?: Record; + body?: unknown; + }): Promise<{ status: number; headers: Record; data: T }> { + const headers = new Headers(req.headers ?? {}); + const init: RequestInit = { + method: req.method, + headers, + credentials: "include", + }; + + if (typeof req.body !== "undefined") { + if ( + typeof req.body === "string" || + req.body instanceof ArrayBuffer || + req.body instanceof Blob || + req.body instanceof FormData || + req.body instanceof URLSearchParams || + req.body instanceof ReadableStream + ) { + init.body = req.body; + } else { + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + init.body = JSON.stringify(req.body); + } + } + + const res = await fetch(req.path, init); + const outHeaders: Record = {}; + res.headers.forEach((value, key) => { + outHeaders[key] = value; + }); + + const contentType = res.headers.get("content-type") ?? ""; + let data: unknown; + if (contentType.includes("application/json")) { + data = await res.json(); + } else { + data = await res.text(); + } + + return { status: res.status, headers: outHeaders, data: data as T }; + } +} diff --git a/packages/sdk/src/client/transport/ws.ts b/packages/sdk/src/client/transport/ws.ts new file mode 100644 index 00000000..6cbff96a --- /dev/null +++ b/packages/sdk/src/client/transport/ws.ts @@ -0,0 +1,119 @@ +import type { DroppyWsState, DroppyWsTransport } from "../../sdk/transport.js"; + +export class DroppyClientWsTransport implements DroppyWsTransport { + private ws: WebSocket | undefined; + private wsUrl: string | undefined; + private currentState: DroppyWsState = "idle"; + private messageListeners = new Set<(msg: unknown) => void>(); + private stateListeners = new Set<(s: DroppyWsState) => void>(); + + constructor(wsUrl?: string) { + this.wsUrl = wsUrl; + } + + setUrl(wsUrl: string) { + this.wsUrl = wsUrl; + } + + private setState(s: DroppyWsState) { + this.currentState = s; + for (const cb of this.stateListeners) cb(s); + } + + connect(_opts?: { headers?: Record }): Promise { + if (!this.wsUrl) { + throw new Error( + "WebSocket URL is required (call setUrl(wsUrl) or pass it to constructor)", + ); + } + + if ( + this.ws && + (this.ws.readyState === WebSocket.OPEN || + this.ws.readyState === WebSocket.CONNECTING) + ) { + return Promise.resolve(); + } + + this.setState("connecting"); + + return new Promise((resolve, reject) => { + const ws = new WebSocket(this.wsUrl as string); + this.ws = ws; + + ws.onopen = () => { + this.setState("open"); + resolve(); + }; + + ws.onclose = () => { + this.setState("closed"); + }; + + ws.onerror = () => { + // browser doesn't provide useful error details here + if (this.currentState !== "open") { + this.setState("closed"); + reject(new Error("WebSocket connection failed")); + } + }; + + ws.onmessage = (evt) => { + let payload: unknown = evt.data; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + // keep as string + } + } + for (const cb of this.messageListeners) { + cb(payload); + } + }; + }); + } + + close(code?: number, reason?: string): void { + if (!this.ws) return; + try { + this.ws.close(code, reason); + } finally { + this.ws = undefined; + this.setState("closed"); + } + } + + state(): DroppyWsState { + return this.currentState; + } + + async send(msg: T) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + // try reconnect + await this.connect(); + } + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not open, and could not reconnected"); + } + + if (typeof msg === "string" || msg instanceof ArrayBuffer) { + this.ws.send(msg); + return; + } + + this.ws.send(JSON.stringify(msg)); + } + + onMessage(cb: (msg: T) => void): () => void { + const handler = cb as unknown as (msg: unknown) => void; + this.messageListeners.add(handler); + return () => this.messageListeners.delete(handler); + } + + onStateChange(cb: (s: DroppyWsState) => void): () => void { + this.stateListeners.add(cb); + return () => this.stateListeners.delete(cb); + } +} diff --git a/packages/sdk/src/sdk/errors/error.ts b/packages/sdk/src/sdk/errors/error.ts new file mode 100644 index 00000000..3afb5c82 --- /dev/null +++ b/packages/sdk/src/sdk/errors/error.ts @@ -0,0 +1,14 @@ +export abstract class DroppyError extends Error { + public readonly isDroppyError = true; + + constructor( + public readonly errorType: string, + message: string, + ) { + super(message); + } +} + +export function isDroppyError(error: unknown): error is DroppyError { + return error instanceof DroppyError; +} diff --git a/packages/sdk/src/sdk/errors/http-error.ts b/packages/sdk/src/sdk/errors/http-error.ts new file mode 100644 index 00000000..1ce3efec --- /dev/null +++ b/packages/sdk/src/sdk/errors/http-error.ts @@ -0,0 +1,12 @@ +import { DroppyError } from "./error.js"; + +export class DroppyHttpError extends DroppyError { + static type = "http-error"; + + constructor( + public status: number, + public message: string, + ) { + super(DroppyHttpError.type, message); + } +} diff --git a/packages/sdk/src/sdk/index.ts b/packages/sdk/src/sdk/index.ts new file mode 100644 index 00000000..b55bcc0e --- /dev/null +++ b/packages/sdk/src/sdk/index.ts @@ -0,0 +1,4 @@ +export * from "./errors/error.js"; +export * from "./errors/http-error.js"; +export * from "./sdk.js"; +export * from "./transport.js"; diff --git a/packages/sdk/src/sdk/sdk.ts b/packages/sdk/src/sdk/sdk.ts new file mode 100644 index 00000000..1cb20303 --- /dev/null +++ b/packages/sdk/src/sdk/sdk.ts @@ -0,0 +1,145 @@ +import { DroppyHttpError } from "./errors/http-error.js"; +import type { DroppyTransportBundle } from "./transport.js"; + +export abstract class DroppySDK { + private _transport: DroppyTransportBundle; + + constructor(public hostname?: string) { + this._onConstruct(); + this._transport = this._getTransport(); + } + + protected resolveUrl(path: string): string { + // allow callers to pass an absolute URL + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + + this.verifyHostname(); + + let rootPath = this.getRootPath?.() ?? "/"; + if (!rootPath.startsWith("/")) { + rootPath = `/${rootPath}`; + } + if (!rootPath.endsWith("/")) { + rootPath = `${rootPath}/`; + } + + // If the caller passes `/foo`, treat it as relative to rootPath, not absolute to origin. + const relPath = path.startsWith("/") ? path.slice(1) : path; + + const base = new URL(this.hostname as string); + base.pathname = rootPath; + return new URL(relPath, base).toString(); + } + + // -------------------------------------------------- + // User management + // -------------------------------------------------- + + /** + * Create the first user on the server + * @param username + * @param password + * @throws DroppyHttpError if the request fails + */ + async createFirstUser(username: string, password: string) { + const res = await this.getTransport().http.request({ + method: "POST", + + path: this.resolveUrl("!/adduser"), + body: { + username, + password, + path: this.getRootPath(), + }, + }); + + if (res.status !== 200) { + throw new DroppyHttpError(res.status, "Failed to add user"); + } + } + + async login(username: string, password: string, remember: boolean) { + try { + const res = await this.getTransport().http.request({ + method: "POST", + path: this.resolveUrl("!/login"), + body: { + username, + password, + remember, + path: this.getRootPath(), + }, + }); + + if (res.status !== 200) { + return false; + } + } catch { + return false; + } + + return true; + } + + async logout() { + try { + const res = await this.getTransport().http.request({ + method: "POST", + path: this.resolveUrl("!/logout"), + body: { + path: this.getRootPath(), + }, + }); + + if (res.status !== 200) { + return false; + } + } catch { + return false; + } + + return true; + } + + verifyHostname(): void { + if (!this.hostname) { + throw new Error("Hostname is required"); + } + if (!this.hostname.startsWith("http")) { + throw new Error( + "Hostname must start with http or https, for example: 'http://127.0.0.1:8989', 'https://droppy.example.org'", + ); + } + } + + abstract getRootPath(): string; + + // -------------------------------------------------- + // Transport + // -------------------------------------------------- + + /** + * Do not call this method directly. Use getTransport() instead. + * @returns A new instance of the transport bundle. + */ + abstract _getTransport(): DroppyTransportBundle; + + /** + * Get the transport bundle. + * @returns The transport bundle. + */ + public getTransport(): DroppyTransportBundle { + if (!this._transport) { + this._transport = this._getTransport(); + } + return this._transport; + } + + // -------------------------------------------------- + // Internal methods + // -------------------------------------------------- + + abstract _onConstruct(): void; +} diff --git a/packages/sdk/src/sdk/transport.ts b/packages/sdk/src/sdk/transport.ts new file mode 100644 index 00000000..e1ea70b2 --- /dev/null +++ b/packages/sdk/src/sdk/transport.ts @@ -0,0 +1,28 @@ +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export interface DroppyHttpTransport { + request(req: { + method: HttpMethod; + path: string; + headers?: Record; + body?: any; + }): Promise<{ status: number; headers: Record; data: T }>; +} + +export type DroppyWsState = "idle" | "connecting" | "open" | "closed"; + +export interface DroppyWsTransport { + connect(opts?: { headers?: Record }): Promise; + close(code?: number, reason?: string): void; + + state(): DroppyWsState; + + send(msg: T): Promise; + onMessage(cb: (msg: T) => void): () => void; + onStateChange?(cb: (s: DroppyWsState) => void): () => void; +} + +export interface DroppyTransportBundle { + http: DroppyHttpTransport; + ws?: DroppyWsTransport; +} diff --git a/packages/sdk/src/server/index.ts b/packages/sdk/src/server/index.ts new file mode 100644 index 00000000..c91eda20 --- /dev/null +++ b/packages/sdk/src/server/index.ts @@ -0,0 +1,27 @@ +import { DroppySDK } from "../sdk/sdk.js"; +import { DroppyServerHttpTransport } from "./transport/http.js"; +import { DroppyServerWsTransport } from "./transport/ws.js"; + +export class DroppyServerSDK extends DroppySDK { + getRootPath() { + return "/"; + } + + _onConstruct(): void { + if (!this.hostname) { + throw new Error("Hostname is required for server-side SDK"); + } + this.verifyHostname(); + } + + _getTransport() { + return { + http: new DroppyServerHttpTransport(), + ws: new DroppyServerWsTransport(), + }; + } +} + +export * from "../sdk/index.js"; +export * from "./transport/http.js"; +export * from "./transport/ws.js"; diff --git a/packages/sdk/src/server/transport/http.ts b/packages/sdk/src/server/transport/http.ts new file mode 100644 index 00000000..b99f2603 --- /dev/null +++ b/packages/sdk/src/server/transport/http.ts @@ -0,0 +1,105 @@ +import type { DroppyHttpTransport, HttpMethod } from "../../sdk/transport.js"; + +export class DroppyServerHttpTransport implements DroppyHttpTransport { + private cookies = new Map(); + + private applySetCookie(setCookie: string) { + const firstPart = setCookie.split(";", 1)[0]?.trim(); + if (!firstPart) { + return; + } + const eqIdx = firstPart.indexOf("="); + if (eqIdx <= 0) { + return; + } + + const name = firstPart.slice(0, eqIdx).trim(); + const value = firstPart.slice(eqIdx + 1).trim(); + + if (!name) { + return; + } + + if (value === "") { + this.cookies.delete(name); + } else { + this.cookies.set(name, value); + } + } + + private updateCookiesFromResponse(res: Response) { + const setCookies = res.headers.getSetCookie(); + if (setCookies && setCookies.length > 0) { + for (const sc of setCookies) { + this.applySetCookie(sc); + } + return; + } + + const single = res.headers.get("set-cookie"); + if (single) { + this.applySetCookie(single); + } + } + + private cookieHeaderValue(): string | undefined { + if (this.cookies.size === 0) return; + return [...this.cookies.entries()] + .map(([k, v]) => `${k}=${v}`) + .join("; "); + } + + async request(req: { + method: HttpMethod; + path: string; + headers?: Record; + body?: unknown; + }): Promise<{ status: number; headers: Record; data: T }> { + const headers = new Headers(req.headers ?? {}); + + if (!headers.has("cookie")) { + const cookie = this.cookieHeaderValue(); + if (cookie) { + headers.set("cookie", cookie); + } + } + + const init: RequestInit = { + method: req.method, + headers, + }; + + if (typeof req.body !== "undefined") { + if ( + typeof req.body === "string" || + req.body instanceof ArrayBuffer || + req.body instanceof Uint8Array || + req.body instanceof URLSearchParams + ) { + init.body = req.body; + } else { + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + init.body = JSON.stringify(req.body); + } + } + + const res = await fetch(req.path, init); + this.updateCookiesFromResponse(res); + const outHeaders: Record = {}; + res.headers.forEach((value, key) => { + outHeaders[key] = value; + }); + + const contentType = res.headers.get("content-type") ?? ""; + let data: unknown; + if (contentType.includes("application/json")) { + data = await res.json(); + } else { + data = await res.text(); + } + + return { status: res.status, headers: outHeaders, data: data as T }; + } +} diff --git a/packages/sdk/src/server/transport/ws.ts b/packages/sdk/src/server/transport/ws.ts new file mode 100644 index 00000000..3722a6dc --- /dev/null +++ b/packages/sdk/src/server/transport/ws.ts @@ -0,0 +1,133 @@ +import { WebSocket } from "ws"; + +import type { DroppyWsState, DroppyWsTransport } from "../../sdk/transport.js"; + +export class DroppyServerWsTransport implements DroppyWsTransport { + private ws: WebSocket | undefined; + private wsUrl: string | undefined; + private currentState: DroppyWsState = "idle"; + private messageListeners = new Set<(msg: unknown) => void>(); + private stateListeners = new Set<(s: DroppyWsState) => void>(); + + constructor(wsUrl?: string) { + this.wsUrl = wsUrl; + } + + setUrl(wsUrl: string) { + this.wsUrl = wsUrl; + } + + private setState(s: DroppyWsState) { + this.currentState = s; + for (const cb of this.stateListeners) { + cb(s); + } + } + + async connect(opts?: { headers?: Record }): Promise { + if (!this.wsUrl) { + throw new Error( + "WebSocket URL is required (call setUrl(wsUrl) or pass it to constructor)", + ); + } + + if (this.ws && (this.ws.readyState === 1 || this.ws.readyState === 0)) { + return; + } + + this.setState("connecting"); + + const url = this.wsUrl; + + await new Promise((resolve, reject) => { + const ws = new WebSocket(url, { + headers: opts?.headers, + }); + this.ws = ws; + + ws.on("open", () => { + this.setState("open"); + resolve(); + }); + ws.on("close", () => { + this.setState("closed"); + }); + ws.on("error", (err) => { + if (this.currentState !== "open") { + this.setState("closed"); + reject(err); + } + }); + ws.on("message", (data) => { + let payload: unknown = data; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + // keep as string + } + } else { + const s = data.toString("utf8"); + try { + payload = JSON.parse(s); + } catch { + payload = s; + } + } + + for (const cb of this.messageListeners) { + cb(payload); + } + }); + }); + } + + close(code?: number, reason?: string): void { + if (!this.ws) { + return; + } + try { + this.ws.close(code, reason); + } finally { + this.ws = undefined; + this.setState("closed"); + } + } + + state(): DroppyWsState { + return this.currentState; + } + + async send(msg: T) { + if (!this.ws || this.ws.readyState !== 1) { + // try reconnect + await this.connect(); + } + + if (!this.ws || this.ws.readyState !== 1) { + throw new Error("WebSocket is not open, and could not reconnect"); + } + + if ( + typeof msg === "string" || + msg instanceof ArrayBuffer || + msg instanceof Uint8Array + ) { + this.ws.send(msg); + return; + } + + this.ws.send(JSON.stringify(msg)); + } + + onMessage(cb: (msg: T) => void): () => void { + const handler = cb as unknown as (msg: unknown) => void; + this.messageListeners.add(handler); + return () => this.messageListeners.delete(handler); + } + + onStateChange(cb: (s: DroppyWsState) => void): () => void { + this.stateListeners.add(cb); + return () => this.stateListeners.delete(cb); + } +} diff --git a/packages/sdk/tsconfig.base.json b/packages/sdk/tsconfig.base.json new file mode 100644 index 00000000..4e2e734f --- /dev/null +++ b/packages/sdk/tsconfig.base.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noImplicitAny": false + } +} diff --git a/packages/sdk/tsconfig.client.json b/packages/sdk/tsconfig.client.json new file mode 100644 index 00000000..5d30ea42 --- /dev/null +++ b/packages/sdk/tsconfig.client.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/client", "src/shared"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..77bc4567 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.server.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/sdk/tsconfig.server.json b/packages/sdk/tsconfig.server.json new file mode 100644 index 00000000..55e6e4e8 --- /dev/null +++ b/packages/sdk/tsconfig.server.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["src/server", "src/shared"] +} diff --git a/yarn.lock b/yarn.lock index 9499d89e..4d8ad9c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2725,6 +2725,12 @@ __metadata: languageName: unknown linkType: soft +"@droppyjs/sdk@workspace:packages/sdk": + version: 0.0.0-use.local + resolution: "@droppyjs/sdk@workspace:packages/sdk" + languageName: unknown + linkType: soft + "@droppyjs/server@workspace:^, @droppyjs/server@workspace:packages/server": version: 0.0.0-use.local resolution: "@droppyjs/server@workspace:packages/server" @@ -2778,6 +2784,188 @@ __metadata: languageName: unknown linkType: soft +"@esbuild/aix-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/aix-ppc64@npm:0.27.2" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm64@npm:0.27.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-arm@npm:0.27.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/android-x64@npm:0.27.2" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-arm64@npm:0.27.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/darwin-x64@npm:0.27.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-arm64@npm:0.27.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/freebsd-x64@npm:0.27.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm64@npm:0.27.2" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-arm@npm:0.27.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ia32@npm:0.27.2" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-loong64@npm:0.27.2" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-mips64el@npm:0.27.2" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-ppc64@npm:0.27.2" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-riscv64@npm:0.27.2" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-s390x@npm:0.27.2" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/linux-x64@npm:0.27.2" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-arm64@npm:0.27.2" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/netbsd-x64@npm:0.27.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-arm64@npm:0.27.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openbsd-x64@npm:0.27.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/openharmony-arm64@npm:0.27.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/sunos-x64@npm:0.27.2" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-arm64@npm:0.27.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-ia32@npm:0.27.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.2": + version: 0.27.2 + resolution: "@esbuild/win32-x64@npm:0.27.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -5906,6 +6094,7 @@ __metadata: clean-css: "npm:5.1.5" colorette: "npm:1.3.0" content-disposition: "npm:0.5.3" + esbuild: "npm:^0.27.2" escape-string-regexp: "npm:5.0.0" etag: "npm:1.8.1" globals: "npm:^15.9.0" @@ -6105,6 +6294,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:^0.27.2": + version: 0.27.2 + resolution: "esbuild@npm:0.27.2" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.2" + "@esbuild/android-arm": "npm:0.27.2" + "@esbuild/android-arm64": "npm:0.27.2" + "@esbuild/android-x64": "npm:0.27.2" + "@esbuild/darwin-arm64": "npm:0.27.2" + "@esbuild/darwin-x64": "npm:0.27.2" + "@esbuild/freebsd-arm64": "npm:0.27.2" + "@esbuild/freebsd-x64": "npm:0.27.2" + "@esbuild/linux-arm": "npm:0.27.2" + "@esbuild/linux-arm64": "npm:0.27.2" + "@esbuild/linux-ia32": "npm:0.27.2" + "@esbuild/linux-loong64": "npm:0.27.2" + "@esbuild/linux-mips64el": "npm:0.27.2" + "@esbuild/linux-ppc64": "npm:0.27.2" + "@esbuild/linux-riscv64": "npm:0.27.2" + "@esbuild/linux-s390x": "npm:0.27.2" + "@esbuild/linux-x64": "npm:0.27.2" + "@esbuild/netbsd-arm64": "npm:0.27.2" + "@esbuild/netbsd-x64": "npm:0.27.2" + "@esbuild/openbsd-arm64": "npm:0.27.2" + "@esbuild/openbsd-x64": "npm:0.27.2" + "@esbuild/openharmony-arm64": "npm:0.27.2" + "@esbuild/sunos-x64": "npm:0.27.2" + "@esbuild/win32-arm64": "npm:0.27.2" + "@esbuild/win32-ia32": "npm:0.27.2" + "@esbuild/win32-x64": "npm:0.27.2" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/cf83f626f55500f521d5fe7f4bc5871bec240d3deb2a01fbd379edc43b3664d1167428738a5aad8794b35d1cca985c44c375b1cd38a2ca613c77ced2c83aafcd + languageName: node + linkType: hard + "escalade@npm:^3.1.1": version: 3.1.1 resolution: "escalade@npm:3.1.1"