diff --git a/package.json b/package.json index a1fd132..e0dbb42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "colyseus.js", - "version": "0.15.14", + "version": "0.15.15-preview.22", "description": "Colyseus Multiplayer SDK for JavaScript/TypeScript", "author": "Endel Dreyer", "license": "MIT", diff --git a/src/Auth.ts b/src/Auth.ts index 5b2aa29..2c20df0 100644 --- a/src/Auth.ts +++ b/src/Auth.ts @@ -1,206 +1,183 @@ -import * as http from "httpie"; -import { getItem, setItem, removeItem } from "./Storage"; +import { HTTP } from "./HTTP"; +import { getItem, removeItem, setItem } from "./Storage"; +import { createNanoEvents } from './core/nanoevents'; -const TOKEN_STORAGE = "colyseus-auth-token"; - -export enum Platform { - ios = "ios", - android = "android", +export interface AuthSettings { + path: string; + key: string; } -export interface Device { - id: string, - platform: Platform +export interface PopupSettings { + prefix: string; + width: number; + height: number; } -export interface IStatus { - status: boolean; +export interface AuthData { + user: any; + token: string; } -export interface IUser { - _id: string; - username: string; - displayName: string; - avatarUrl: string; - - isAnonymous: boolean; - email: string; - - lang: string; - location: string; - timezone: string; - metadata: any; +export class Auth { + settings: AuthSettings = { + path: "/auth", + key: "colyseus-auth-token", + }; - devices: Device[]; + #_initialized = false; + #_initializationPromise: Promise; + #_signInWindow = undefined; + #_events = createNanoEvents(); - facebookId: string; - twitterId: string; - googleId: string; - gameCenterId: string; - steamId: string; - - friendIds: string[]; - blockedUserIds: string[]; + constructor(protected http: HTTP) { + getItem(this.settings.key, (token) => this.token = token); + } - createdAt: Date; - updatedAt: Date; -} + public set token(token: string) { + this.http.authToken = token; + } -export class Auth implements IUser { - _id: string = undefined; - username: string = undefined; - displayName: string = undefined; - avatarUrl: string = undefined; + public get token(): string { + return this.http.authToken; + } - isAnonymous: boolean = undefined; - email: string = undefined; + public onChange(callback: (response: AuthData) => void) { + const unbindChange = this.#_events.on("change", callback); + if (!this.#_initialized) { + this.#_initializationPromise = new Promise((resolve, reject) => { + this.getUserData().then((userData) => { + this.emitChange({ ...userData, token: this.token }); - lang: string = undefined; - location: string = undefined; - timezone: string = undefined; - metadata: any = undefined; + }).catch((e) => { + // user is not logged in, or service is down + this.emitChange({ user: null, token: undefined }); - devices: Device[] = undefined; + }).finally(() => { + resolve(); + }); + }); + } + this.#_initialized = true; + return unbindChange; + } - facebookId: string = undefined; - twitterId: string = undefined; - googleId: string = undefined; - gameCenterId: string = undefined; - steamId: string = undefined; + public async getUserData() { + if (this.token) { + return (await this.http.get(`${this.settings.path}/userdata`)).data; + } else { + throw new Error("missing auth.token"); + } + } - friendIds: string[] = undefined; - blockedUserIds: string[] = undefined; + public async registerWithEmailAndPassword(email: string, password: string, options?: any) { + const data = (await this.http.post(`${this.settings.path}/register`, { + body: { email, password, options, }, + })).data; - createdAt: Date = undefined; - updatedAt: Date = undefined; + this.emitChange(data); - // auth token - token: string = undefined; + return data; + } - protected endpoint: string; - protected keepOnlineInterval: any; + public async signInWithEmailAndPassword(email: string, password: string) { + const data = (await this.http.post(`${this.settings.path}/login`, { + body: { email, password, }, + })).data; - constructor(endpoint: string) { - this.endpoint = endpoint.replace("ws", "http"); - getItem(TOKEN_STORAGE, (token) => this.token = token); - } + this.emitChange(data); - get hasToken() { - return !!this.token; + return data; } - async login (options: { - accessToken?: string, - deviceId?: string, - platform?: string, - email?: string, - password?: string, - } = {}) { - const queryParams: any = Object.assign({}, options); - - if (this.hasToken) { - queryParams.token = this.token; - } + public async signInAnonymously(options?: any) { + const data = (await this.http.post(`${this.settings.path}/anonymous`, { + body: { options, } + })).data; - const data = await this.request('post', '/auth', queryParams); + this.emitChange(data); - // set & cache token - this.token = data.token; - setItem(TOKEN_STORAGE, this.token); + return data; + } - for (let attr in data) { - if (this.hasOwnProperty(attr)) { this[attr] = data[attr]; } - } + public async sendPasswordResetEmail(email: string) { + const data = (await this.http.post(`${this.settings.path}/forgot-password`, { + body: { email, } + })).data; - this.registerPingService(); + this.emitChange(data); - return this; + return data; } - async save() { - await this.request('put', '/auth', {}, { - username: this.username, - displayName: this.displayName, - avatarUrl: this.avatarUrl, - lang: this.lang, - location: this.location, - timezone: this.timezone, - }); - - return this; - } + public async signInWithProvider(providerName: string, settings: Partial = {}) { + return new Promise((resolve, reject) => { + const w = settings.width || 480; + const h = settings.height || 768; - async getFriends() { - return (await this.request('get', '/friends/all')) as IUser[]; - } + // forward existing token for upgrading + const upgradingToken = this.token ? `?token=${this.token}` : ""; - async getOnlineFriends() { - return (await this.request('get', '/friends/online')) as IUser[]; - } + // Capitalize first letter of providerName + const title = `Login with ${(providerName[0].toUpperCase() + providerName.substring(1))}`; + const url = this.http['client']['getHttpEndpoint'](`${(settings.prefix || `${this.settings.path}/provider`)}/${providerName}${upgradingToken}`); - async getFriendRequests() { - return (await this.request('get', '/friends/requests')) as IUser[]; - } + const left = (screen.width / 2) - (w / 2); + const top = (screen.height / 2) - (h / 2); - async sendFriendRequest(friendId: string) { - return (await this.request('post', '/friends/requests', { userId: friendId })) as IStatus; - } + this.#_signInWindow = window.open(url, title, 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=' + w + ', height=' + h + ', top=' + top + ', left=' + left); - async acceptFriendRequest(friendId: string) { - return (await this.request('put', '/friends/requests', { userId: friendId })) as IStatus; - } + const onMessage = (event: MessageEvent) => { + // TODO: it is a good idea to check if event.origin can be trusted! + // if (event.origin.indexOf(window.location.hostname) === -1) { return; } - async declineFriendRequest(friendId: string) { - return (await this.request('del', '/friends/requests', { userId: friendId })) as IStatus; - } + // require 'user' and 'token' inside received data. + if (event.data.user === undefined && event.data.token === undefined) { return; } - async blockUser(friendId: string) { - return (await this.request('post', '/friends/block', { userId: friendId })) as IStatus; - } + clearInterval(rejectionChecker); + this.#_signInWindow.close(); + this.#_signInWindow = undefined; - async unblockUser(friendId: string) { - return (await this.request('put', '/friends/block', { userId: friendId })) as IStatus; - } + window.removeEventListener("message", onMessage); - async request( - method: 'get' | 'post' | 'put' | 'del', - segments: string, - query: {[key: string]: number | string} = {}, - body?: any, - headers: {[key: string]: string} = {} - ) { - headers['Accept'] = 'application/json'; - if (this.hasToken) { headers['Authorization'] = 'Bearer ' + this.token; } - - const queryParams: string[] = []; - for (const name in query) { - queryParams.push(`${name}=${query[name]}`); - } + if (event.data.error !== undefined) { + reject(event.data.error); - const queryString = (queryParams.length > 0) - ? `?${queryParams.join("&")}` - : ''; + } else { + resolve(event.data); + this.emitChange(event.data); + } + } - const opts: Partial = { headers }; - if (body) { opts.body = body; } + const rejectionChecker = setInterval(() => { + if (!this.#_signInWindow || this.#_signInWindow.closed) { + this.#_signInWindow = undefined; + reject("cancelled"); + window.removeEventListener("message", onMessage); + } + }, 200); - return (await http[method](`${this.endpoint}${segments}${queryString}`, opts)).data; + window.addEventListener("message", onMessage); + }); } - logout() { - this.token = undefined; - removeItem(TOKEN_STORAGE); - this.unregisterPingService(); + public async signOut() { + this.emitChange({ user: null, token: null }); } - registerPingService(timeout: number = 15000) { - this.unregisterPingService(); + private emitChange(authData: Partial) { + if (authData.token !== undefined) { + this.token = authData.token; - this.keepOnlineInterval = setInterval(() => this.request('get', '/auth'), timeout); - } + if (authData.token === null) { + removeItem(this.settings.key); + + } else { + // store key in localStorage + setItem(this.settings.key, authData.token); + } + } - unregisterPingService() { - clearInterval(this.keepOnlineInterval); + this.#_events.emit("change", authData); } } diff --git a/src/Client.ts b/src/Client.ts index 098af45..9c8401b 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,8 +1,8 @@ -import { post, get } from "httpie"; - import { ServerError } from './errors/ServerError'; import { Room, RoomAvailable } from './Room'; import { SchemaConstructor } from './serializer/SchemaSerializer'; +import { HTTP } from "./HTTP"; +import { Auth } from './Auth'; export type JoinOptions = any; @@ -29,6 +29,9 @@ export interface EndpointSettings { } export class Client { + public http: HTTP; + public auth: Auth; + protected settings: EndpointSettings; constructor(settings: string | EndpointSettings = DEFAULT_ENDPOINT) { @@ -42,7 +45,7 @@ export class Client { this.settings = { hostname: url.hostname, - pathname: url.pathname !== "/" ? url.pathname : "", + pathname: url.pathname, port, secure }; @@ -59,6 +62,14 @@ export class Client { } this.settings = settings; } + + // make sure pathname does not end with "/" + if (this.settings.pathname.endsWith("/")) { + this.settings.pathname = this.settings.pathname.slice(0, -1); + } + + this.http = new HTTP(this); + this.auth = new Auth(this.http); } public async joinOrCreate(roomName: string, options: JoinOptions = {}, rootSchema?: SchemaConstructor) { @@ -94,7 +105,7 @@ export class Client { public async getAvailableRooms(roomName: string = ""): Promise[]> { return ( - await get(this.getHttpEndpoint(`${roomName}`), { + await this.http.get(`matchmake/${roomName}`, { headers: { 'Accept': 'application/json' } @@ -165,7 +176,7 @@ export class Client { reuseRoomInstance?: Room, ) { const response = ( - await post(this.getHttpEndpoint(`${method}/${roomName}`), { + await this.http.post(`matchmake/${method}/${roomName}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' @@ -174,6 +185,7 @@ export class Client { }) ).data; + // FIXME: HTTP class is already handling this as ServerError. if (response.error) { throw new MatchMakeError(response.error, response.code); } @@ -193,6 +205,7 @@ export class Client { protected buildEndpoint(room: any, options: any = {}) { const params = []; + // append provided options for (const name in options) { if (!options.hasOwnProperty(name)) { continue; @@ -215,7 +228,8 @@ export class Client { } protected getHttpEndpoint(segments: string = '') { - return `${(this.settings.secure) ? "https" : "http"}://${this.settings.hostname}${this.getEndpointPort()}${this.settings.pathname}/matchmake/${segments}`; + const path = segments.startsWith("/") ? segments : `/${segments}`; + return `${(this.settings.secure) ? "https" : "http"}://${this.settings.hostname}${this.getEndpointPort()}${this.settings.pathname}${path}`; } protected getEndpointPort() { diff --git a/src/HTTP.ts b/src/HTTP.ts new file mode 100644 index 0000000..1007ebf --- /dev/null +++ b/src/HTTP.ts @@ -0,0 +1,44 @@ +import { Client } from "./Client"; +import { ServerError } from "./errors/ServerError"; +import * as httpie from "httpie"; + +export class HTTP { + public authToken: string; + + constructor(protected client: Client) {} + + public get(path: string, options: Partial = {}): Promise> { + return this.request("get", path, options); + } + + public post(path: string, options: Partial = {}): Promise> { + return this.request("post", path, options); + } + + public del(path: string, options: Partial = {}): Promise> { + return this.request("del", path, options); + } + + public put(path: string, options: Partial = {}): Promise> { + return this.request("put", path, options); + } + + protected request(method: "get" | "post" | "put" | "del", path: string, options: Partial = {}): Promise { + return httpie[method](this.client['getHttpEndpoint'](path), this.getOptions(options)).catch((e: any) => { + throw new ServerError(e.statusCode || -1, e.data?.error || e.statusMessage || e.message || "offline"); + }); + } + + protected getOptions(options: Partial) { + if (this.authToken) { + if (!options.headers) { + options.headers = {}; + } + + options.headers['Authorization'] = `Bearer ${this.authToken}`; + options.withCredentials = true; + } + + return options; + } +} diff --git a/src/index.ts b/src/index.ts index 78d82d3..531f88b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import './legacy'; export { Client, JoinOptions } from './Client'; export { Protocol, ErrorCode } from './Protocol'; export { Room, RoomAvailable } from './Room'; -export { Auth, Platform, Device } from "./Auth"; +export { Auth, type AuthSettings, type PopupSettings } from "./Auth"; /* * Serializers diff --git a/test/auth_test.ts b/test/auth_test.ts new file mode 100644 index 0000000..44d5675 --- /dev/null +++ b/test/auth_test.ts @@ -0,0 +1,47 @@ +import './util'; +import assert from "assert"; +import { Client, Room } from "../src"; +import { AuthData } from '../src/Auth'; + +describe("Auth", function() { + let client: Client; + + before(() => { + client = new Client("ws://localhost:2546"); + }); + + describe("store token", () => { + it("should store token on localStorage", () => { + client.auth['emitChange']({ user: {}, token: "123" }); + assert.strictEqual("123", client.auth.token); + assert.strictEqual("123", window.localStorage.getItem(client.auth.settings.key)); + }); + + it("should reject if no token is stored", async () => { + // @ts-ignore + client.auth.token = undefined; + + await assert.rejects(async () => { + await client.auth.getUserData(); + }, /missing auth.token/); + }); + + }); + + describe("onChange", () => { + it("should trigger onChange when token is set", () => { + let onChangePayload: AuthData | undefined = undefined; + client.auth.onChange((data) => onChangePayload = data); + client.auth['emitChange']({ user: { dummy: true }, token: "123" }); + assert.strictEqual("123", client.auth.token); + assert.strictEqual("123", client.http.authToken); + + client.auth.onChange((data) => onChangePayload = data); + client.auth['emitChange']({ user: { dummy: true }, token: null } as any); + assert.strictEqual(null, client.auth.token); + assert.strictEqual(null, client.http.authToken); + }); + + }); + +}); diff --git a/test/client_test.ts b/test/client_test.ts index a1dc7f5..b877276 100644 --- a/test/client_test.ts +++ b/test/client_test.ts @@ -18,25 +18,25 @@ describe("Client", function () { const settingsByUrl = { 'ws://localhost:2567': { settings: { hostname: "localhost", port: 2567, secure: false, }, - httpEndpoint: "http://localhost:2567", + httpEndpoint: "http://localhost:2567/", wsEndpoint: "ws://localhost:2567/processId/roomId?", wsEndpointPublicAddress: "ws://node-1.colyseus.cloud/processId/roomId?" }, 'wss://localhost:2567': { settings: { hostname: "localhost", port: 2567, secure: true, }, - httpEndpoint: "https://localhost:2567", + httpEndpoint: "https://localhost:2567/", wsEndpoint: "wss://localhost:2567/processId/roomId?", wsEndpointPublicAddress: "wss://node-1.colyseus.cloud/processId/roomId?" }, 'http://localhost': { settings: { hostname: "localhost", port: 80, secure: false, }, - httpEndpoint: "http://localhost", + httpEndpoint: "http://localhost/", wsEndpoint: "ws://localhost/processId/roomId?", wsEndpointPublicAddress: "ws://node-1.colyseus.cloud/processId/roomId?" }, 'https://localhost/custom/path': { settings: { hostname: "localhost", port: 443, secure: true, pathname: "/custom/path" }, - httpEndpoint: "https://localhost/custom/path", + httpEndpoint: "https://localhost/custom/path/", wsEndpoint: "wss://localhost/custom/path/processId/roomId?", wsEndpointPublicAddress: "wss://node-1.colyseus.cloud/processId/roomId?" }, @@ -49,7 +49,7 @@ describe("Client", function () { assert.strictEqual(expected.settings.hostname, settings.hostname); assert.strictEqual(expected.settings.port, settings.port); assert.strictEqual(expected.settings.secure, settings.secure); - assert.strictEqual(expected.httpEndpoint + "/matchmake/", client['getHttpEndpoint']()); + assert.strictEqual(expected.httpEndpoint, client['getHttpEndpoint']()); assert.strictEqual(expected.wsEndpoint, client['buildEndpoint'](room)); assert.strictEqual(expected.wsEndpointPublicAddress, client['buildEndpoint'](roomWithPublicAddress)); @@ -57,7 +57,7 @@ describe("Client", function () { assert.strictEqual(expected.settings.hostname, clientWithSettings['settings'].hostname); assert.strictEqual(expected.settings.port, clientWithSettings['settings'].port); assert.strictEqual(expected.settings.secure, clientWithSettings['settings'].secure); - assert.strictEqual(expected.httpEndpoint + "/matchmake/", clientWithSettings['getHttpEndpoint']()); + assert.strictEqual(expected.httpEndpoint, clientWithSettings['getHttpEndpoint']()); assert.strictEqual(expected.wsEndpoint, clientWithSettings['buildEndpoint'](room)); assert.strictEqual(expected.wsEndpointPublicAddress, clientWithSettings['buildEndpoint'](roomWithPublicAddress)); }