Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ coverage/
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `@droppyjs/sdk`

> SDK for droppy. Read more at https://github.com/droppyjs/droppy
54 changes: 54 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@droppyjs/sdk",
"version": "2.0.0-alpha.3",
"description": "> SDK for droppy.",
"author": "droppyjs <hello@droppyjs.com>",
"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"
}
}
55 changes: 55 additions & 0 deletions packages/sdk/scripts/build-client-min.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
32 changes: 32 additions & 0 deletions packages/sdk/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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";
51 changes: 51 additions & 0 deletions packages/sdk/src/client/transport/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { DroppyHttpTransport, HttpMethod } from "../../sdk/transport.js";

export class DroppyClientHttpTransport implements DroppyHttpTransport {
async request<T>(req: {
method: HttpMethod;
path: string;
headers?: Record<string, string>;
body?: unknown;
}): Promise<{ status: number; headers: Record<string, string>; 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<string, string> = {};
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 };
}
}
119 changes: 119 additions & 0 deletions packages/sdk/src/client/transport/ws.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }): Promise<void> {
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<T>(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<T>(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);
}
}
14 changes: 14 additions & 0 deletions packages/sdk/src/sdk/errors/error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions packages/sdk/src/sdk/errors/http-error.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 4 additions & 0 deletions packages/sdk/src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./errors/error.js";
export * from "./errors/http-error.js";
export * from "./sdk.js";
export * from "./transport.js";
Loading
Loading