@roomservice/browser
Advanced tools
Comparing version 0.3.2-0 to 0.4.0-0
import RoomClient from "./room-client"; | ||
import { KeyValueObject } from "./types"; | ||
import { Obj } from "./types"; | ||
export default class RoomServiceClient { | ||
@@ -8,3 +8,3 @@ private readonly _authorizationUrl; | ||
}); | ||
room<T extends KeyValueObject>(roomReference: string, state?: T): RoomClient<T>; | ||
room<T extends Obj>(roomReference: string, defaultDoc?: T): RoomClient; | ||
} |
@@ -9,7 +9,7 @@ "use strict"; | ||
} | ||
room(roomReference, state) { | ||
room(roomReference, defaultDoc) { | ||
return new room_client_1.default({ | ||
authUrl: this._authorizationUrl, | ||
reference: roomReference, | ||
state | ||
roomReference, | ||
defaultDoc | ||
}); | ||
@@ -16,0 +16,0 @@ } |
@@ -10,2 +10,3 @@ "use strict"; | ||
const test_socket_1 = require("./test-socket"); | ||
const lodash_1 = require("lodash"); | ||
const URL = "https://coolsite.com"; | ||
@@ -50,6 +51,10 @@ jest.mock("idb-keyval"); | ||
await room.init(); | ||
const [url, args] = mock.calls[0]; | ||
expect(url).toBe("https://api.roomservice.dev"); | ||
const urls = mock.calls.map(([url]) => url); | ||
const args = mock.calls.map(([_, args]) => args); | ||
expect(lodash_1.uniq(urls.sort())).toStrictEqual([ | ||
"https://api.roomservice.dev", | ||
"https://api.roomservice.dev/v1/presence" | ||
].sort()); | ||
// @ts-ignore because bad typings make me sad | ||
expect(args.transportOptions.polling.extraHeaders.authorization).toBe("Bearer short-lived-token"); | ||
expect(args[0].transportOptions.polling.extraHeaders.authorization).toBe("Bearer short-lived-token"); | ||
}); | ||
@@ -65,3 +70,3 @@ test("room.publish() can change a document", async () => { | ||
sockets.emit("connect"); | ||
const newState = await room.publishDoc(prevState => { | ||
const newState = await room.setDoc((prevState) => { | ||
prevState.someOption = "hello!"; | ||
@@ -106,5 +111,5 @@ }); | ||
const cb = jest.fn(); | ||
room.onUpdateDoc(cb); | ||
room.onSetDoc(cb); | ||
// @ts-ignore private | ||
const onUpdateSocket = room._onUpdateSocketCallback; | ||
const onUpdateSocket = room._docClient._onUpdateSocketCallback; | ||
expect(onUpdateSocket).toBeTruthy(); | ||
@@ -111,0 +116,0 @@ await room.init(); |
@@ -1,42 +0,30 @@ | ||
import { KeyValueObject } from "./types"; | ||
export default class RoomClient<T extends KeyValueObject> { | ||
private readonly _peer; | ||
private readonly _reference; | ||
import { PresenceMeta } from "./presence-client"; | ||
import { Obj } from "./types"; | ||
interface RoomClientParameters { | ||
authUrl: string; | ||
roomReference: string; | ||
defaultDoc?: Obj; | ||
} | ||
export default class RoomClient { | ||
private readonly _docClient; | ||
private readonly _presenceClient; | ||
private readonly _authorizationUrl; | ||
private _socket?; | ||
private _roomId?; | ||
private _doc?; | ||
private _actorId?; | ||
private _socketURL; | ||
private _onUpdateSocketCallback?; | ||
private _onConnectSocketCallback?; | ||
private _onDisconnectSocketCallback?; | ||
private _saveOffline; | ||
constructor(parameters: { | ||
authUrl: string; | ||
reference: string; | ||
state?: T; | ||
}); | ||
private readActorIdThenCreateDoc; | ||
private createDoc; | ||
/** | ||
* Manually attempt to restore the state from offline storage. | ||
*/ | ||
restore(): Promise<T>; | ||
/** | ||
* Attempts to go online. | ||
*/ | ||
private readonly _roomReference; | ||
constructor(parameters: RoomClientParameters); | ||
private set _socketURL(value); | ||
private _init; | ||
init(): Promise<{ | ||
doc: T; | ||
doc: string; | ||
} | { | ||
doc: Obj; | ||
}>; | ||
/** | ||
* Manually goes offline | ||
*/ | ||
restore(): Promise<any>; | ||
onConnect(callback: () => void): void; | ||
onDisconnect(callback: () => void): void; | ||
disconnect(): void; | ||
onUpdateDoc(callback: (state: Readonly<T>) => any): void; | ||
onConnect(callback: () => any): void; | ||
onDisconnect(callback: () => any): void; | ||
private syncOfflineCache; | ||
private _sendMsgToSocket; | ||
publishDoc(callback: (state: T) => void): T; | ||
setDoc<D extends Obj>(change: (prevDoc: D) => void): Readonly<D>; | ||
onSetDoc<D extends Obj>(callback: (newDoc: Readonly<D>) => void): void; | ||
setPresence<P extends Obj>(key: string, value: P): void; | ||
onSetPresence<P extends Obj>(callback: (meta: PresenceMeta, value: P) => void): void; | ||
} | ||
export {}; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
const tslib_1 = require("tslib"); | ||
const automerge_1 = tslib_1.__importStar(require("automerge")); | ||
const immutable_1 = require("immutable"); | ||
const invariant_1 = tslib_1.__importDefault(require("invariant")); | ||
const doc_client_1 = tslib_1.__importDefault(require("./doc-client")); | ||
const presence_client_1 = tslib_1.__importDefault(require("./presence-client")); | ||
const authorize_1 = tslib_1.__importDefault(require("./authorize")); | ||
const lodash_1 = require("lodash"); | ||
const manymerge_1 = require("manymerge"); | ||
const safe_json_stringify_1 = tslib_1.__importDefault(require("safe-json-stringify")); | ||
const authorize_1 = tslib_1.__importDefault(require("./authorize")); | ||
const constants_1 = require("./constants"); | ||
const offline_1 = tslib_1.__importDefault(require("./offline")); | ||
const socket_1 = tslib_1.__importDefault(require("./socket")); | ||
function asRoomStr(room) { | ||
return safe_json_stringify_1.default(room); | ||
} | ||
class RoomClient { | ||
constructor(parameters) { | ||
// We define this as a local variable to make testing easier | ||
this._socketURL = constants_1.ROOM_SERICE_SOCKET_URL; | ||
// The automerge client will call this function when | ||
// it picks up changes from the docset. | ||
// | ||
// WARNING: This function is an arrow function specifically because | ||
// it needs to access this._socket. If you use a regular function, | ||
// it won't work. | ||
this._sendMsgToSocket = (automergeMsg) => { | ||
// we're offline, so don't do anything | ||
if (!this._socket) { | ||
return; | ||
this._init = lodash_1.throttle(async () => { | ||
var _a; | ||
let room; | ||
let session; | ||
try { | ||
const params = await authorize_1.default(this._authorizationUrl, this._roomReference); | ||
room = params.room; | ||
session = params.session; | ||
} | ||
invariant_1.default(this._roomId, "Expected a _roomId to exist when publishing. This is a sign of a broken client, if you're seeing this, please contact us."); | ||
const room = { | ||
meta: { | ||
roomId: this._roomId | ||
}, | ||
payload: { | ||
msg: automergeMsg | ||
catch (err) { | ||
console.warn(err); | ||
} | ||
// We're on the server, so we shouldn't init, because we don't need | ||
// to connect to the clients. | ||
if (typeof window === "undefined") { | ||
// This would signal that the server side can't access the auth endpoint | ||
if (!room) { | ||
throw new Error("Room Service can't access the auth endpoint on the server. More details: https://err.sh/getroomservice/browser/server-side-no-network"); | ||
} | ||
}; | ||
socket_1.default.emit(this._socket, "sync_room_state", asRoomStr(room)); | ||
}; | ||
this._reference = parameters.reference; | ||
return { doc: (_a = room) === null || _a === void 0 ? void 0 : _a.state }; | ||
} | ||
// Presence client | ||
this._presenceClient.init({ | ||
room, | ||
session | ||
}); | ||
// Doc client | ||
const { doc } = await this._docClient.init({ | ||
room, | ||
session | ||
}); | ||
return { doc }; | ||
}, 100, { | ||
leading: true | ||
}); | ||
this._docClient = new doc_client_1.default(parameters); | ||
this._presenceClient = new presence_client_1.default(parameters); | ||
this._authorizationUrl = parameters.authUrl; | ||
this._peer = new manymerge_1.Peer(this._sendMsgToSocket); | ||
// Whenever possible, we try to use the actorId defined in storage | ||
this.readActorIdThenCreateDoc(parameters.state); | ||
// We define this here so we can debounce the save function | ||
// Otherwise we'll get quite the performance hit | ||
let saveOffline = (docId, doc) => { | ||
offline_1.default.setDoc(this._reference, docId, automerge_1.save(doc)); | ||
}; | ||
this._saveOffline = lodash_1.debounce(saveOffline, 120); | ||
this._roomReference = parameters.roomReference; | ||
} | ||
async readActorIdThenCreateDoc(state) { | ||
const actorId = await offline_1.default.getOrCreateActor(); | ||
this._actorId = actorId; | ||
return this.createDoc(actorId, state); | ||
// used for testing locally | ||
set _socketURL(url) { | ||
this._docClient._socketURL = url; | ||
this._presenceClient._socketURL = url; | ||
} | ||
createDoc(actorId, state) { | ||
if (this._doc) { | ||
return this._doc; | ||
} | ||
const defaultDoc = automerge_1.default.from(state || {}, { actorId }); | ||
// Automerge technically supports sending multiple docs | ||
// over the wire at the same time, but for simplicity's sake | ||
// we just use one doc at for the moment. | ||
// | ||
// In the future, we may support multiple documents per room. | ||
this._doc = defaultDoc; | ||
this._peer.notify(this._doc); | ||
return this._doc; | ||
// Start the client, sync from cache, and connect. | ||
// This function is throttled at 100ms, since it's only | ||
// supposed to be called once, but | ||
async init() { | ||
return this._init(); | ||
} | ||
/** | ||
* Manually attempt to restore the state from offline storage. | ||
*/ | ||
// Manually restore from cache | ||
async restore() { | ||
if (!this._doc) { | ||
await this.readActorIdThenCreateDoc(); | ||
} | ||
return this.syncOfflineCache(); | ||
const doc = await this._docClient.restore(); | ||
return doc; | ||
} | ||
/** | ||
* Attempts to go online. | ||
*/ | ||
async init() { | ||
let room; | ||
let session; | ||
if (!this._doc) { | ||
await this.readActorIdThenCreateDoc(); | ||
} | ||
try { | ||
const params = await authorize_1.default(this._authorizationUrl, this._reference); | ||
room = params.room; | ||
session = params.session; | ||
} | ||
catch (err) { | ||
console.warn(err); | ||
await this.syncOfflineCache(); | ||
return { | ||
doc: this._doc | ||
}; | ||
} | ||
this._roomId = room.id; | ||
this._socket = socket_1.default.newSocket(this._socketURL, { | ||
transportOptions: { | ||
polling: { | ||
extraHeaders: { | ||
authorization: "Bearer " + session.token | ||
} | ||
} | ||
} | ||
}); | ||
/** | ||
* Errors | ||
*/ | ||
socket_1.default.on(this._socket, "error", (data) => { | ||
const { message } = JSON.parse(data); | ||
console.error(`Error from Socket: ${message}`); | ||
}); | ||
// Required connect handler | ||
socket_1.default.on(this._socket, "connect", () => { | ||
this._peer.notify(this._doc); | ||
this.syncOfflineCache(); | ||
}); | ||
// Required disconnect handler | ||
socket_1.default.on(this._socket, "disconnect", reason => { | ||
if (reason === "io server disconnect") { | ||
console.warn("The RoomService client was forcibly disconnected from the server, likely due to invalid auth."); | ||
} | ||
}); | ||
/** | ||
* We don't require these to be defined before hand since they're | ||
* optional | ||
*/ | ||
if (this._onUpdateSocketCallback) { | ||
socket_1.default.on(this._socket, "sync_room_state", this._onUpdateSocketCallback); | ||
} | ||
if (this._onConnectSocketCallback) { | ||
socket_1.default.on(this._socket, "connect", this._onConnectSocketCallback); | ||
} | ||
if (this._onDisconnectSocketCallback) { | ||
socket_1.default.on(this._socket, "disconnect", this._onDisconnectSocketCallback); | ||
} | ||
// Merge RoomService's online cache with what we have locally | ||
let state; | ||
try { | ||
// NOTE: we purposefully don't define an actor id, | ||
// since it's not assumed this state is defined by our actor. | ||
state = automerge_1.default.load(room.state); | ||
const local = await this.syncOfflineCache(); | ||
state = automerge_1.merge(local, state); | ||
// @ts-ignore no trust me I swear | ||
this._doc = state; | ||
this._peer.notify(this._doc); | ||
} | ||
catch (err) { | ||
console.error(err); | ||
state = {}; | ||
} | ||
return { doc: state }; | ||
// Connection | ||
onConnect(callback) { | ||
this._docClient.onConnect(callback); | ||
} | ||
/** | ||
* Manually goes offline | ||
*/ | ||
onDisconnect(callback) { | ||
this._docClient.onDisconnect(callback); | ||
} | ||
disconnect() { | ||
if (this._socket) { | ||
socket_1.default.disconnect(this._socket); | ||
} | ||
this._socket = undefined; | ||
this._docClient.disconnect(); | ||
} | ||
onUpdateDoc(callback) { | ||
invariant_1.default(!this._onUpdateSocketCallback, "It looks like you've called onUpdate multiple times. Since this can cause quite severe performance issues if used incorrectly, we're not currently supporting this behavior. If you've got a use-case we haven't thought of, file a github issue and we may change this."); | ||
const socketCallback = async (data) => { | ||
const { meta, payload } = JSON.parse(data); | ||
if (!this._roomId) { | ||
throw new Error("Expected a _roomId to be defined before we invoked the the onUpdate callback. This is a sign of a broken client, please contact us if you're seeing this."); | ||
} | ||
// This socket event will fire for ALL rooms, so we need to check | ||
// if this callback refers to this particular room. | ||
if (meta.roomId !== this._roomId) { | ||
return; | ||
} | ||
if (!payload.msg) { | ||
throw new Error("The room's state object does not include an 'msg' attribute, which could signal a corrupted room. If you're seeing this in production, that's quite bad and represents a fixable bug within the SDK itself. Please let us know and we'll fix it immediately!"); | ||
} | ||
// This is effectively impossible tbh, but we like to be cautious | ||
if (!this._doc) { | ||
await this.readActorIdThenCreateDoc(); | ||
} | ||
// convert the payload clock to a map | ||
payload.msg.clock = immutable_1.Map(payload.msg.clock); | ||
const newDoc = this._peer.applyMessage(payload.msg, this._doc); | ||
// Automerge, in it's infinite wisdom, will just return undefined | ||
// if a message is corrupted in some way that it doesn't like. | ||
// In these cases, we shouldn't actually save it offline otherwise | ||
// we'd create a hard-to-fix corruption. | ||
if (!newDoc) { | ||
throw new Error(`Response from RoomService API seems corrupted, aborting. Response: ${data}`); | ||
} | ||
this._doc = newDoc; | ||
this._saveOffline("default", this._doc); | ||
callback(this._doc); | ||
}; | ||
// If we're offline, just wait till we're back online to assign this callback | ||
if (!this._socket) { | ||
this._onUpdateSocketCallback = socketCallback; | ||
return; | ||
} | ||
socket_1.default.on(this._socket, "sync_room_state", socketCallback); | ||
// Documents | ||
setDoc(change) { | ||
return this._docClient.setDoc(change); | ||
} | ||
onConnect(callback) { | ||
// If we're offline, cue this up for later. | ||
if (!this._socket) { | ||
this._onConnectSocketCallback = callback; | ||
return; | ||
} | ||
this._socket.on("connect", callback); | ||
onSetDoc(callback) { | ||
this._docClient.onSetDoc(callback); | ||
} | ||
onDisconnect(callback) { | ||
// If we're offline, cue this up for later. | ||
if (!this._socket) { | ||
this._onDisconnectSocketCallback = callback; | ||
return; | ||
} | ||
this._socket.on("disconnect", callback); | ||
// Presence | ||
setPresence(key, value) { | ||
this._presenceClient.setPresence(key, value); | ||
} | ||
async syncOfflineCache() { | ||
const data = await offline_1.default.getDoc(this._reference, "default"); | ||
if (!data) { | ||
return this._doc; | ||
} | ||
// We explictly do not add | ||
const offlineDoc = automerge_1.load(data, { | ||
actorId: await offline_1.default.getOrCreateActor() | ||
}); | ||
this._doc = offlineDoc; | ||
this._peer.notify(this._doc); | ||
return offlineDoc; | ||
onSetPresence(callback) { | ||
this._presenceClient.onSetPresence(callback); | ||
} | ||
publishDoc(callback) { | ||
if (!this._doc) { | ||
console.error("Attempting to call publishDoc .init() has finished."); | ||
return {}; | ||
} | ||
if (typeof callback !== "function") { | ||
throw new Error(`room.publishDoc expects a function.`); | ||
} | ||
let newDoc = automerge_1.default.change(this._doc, callback); | ||
if (!newDoc) { | ||
invariant_1.default(!!this._actorId, "The client is trying to regenerate a deleted document, but isn't able to access the cached actor id. This is probably a bug in the client, if you see this, we're incredibly sorry! Please let us know. In the meantime, you may be able work around this by ensuring 'await room.restore()' has finished before calling 'publishState'."); | ||
// this happens if someone deletes the doc, so we should just reinit it. | ||
newDoc = this.createDoc(this._actorId); | ||
} | ||
this._doc = newDoc; | ||
this._saveOffline("default", newDoc); | ||
this._peer.notify(newDoc); | ||
return newDoc; | ||
} | ||
} | ||
exports.default = RoomClient; | ||
//# sourceMappingURL=room-client.js.map |
/// <reference types="socket.io-client" /> | ||
declare const Sockets: { | ||
newSocket(url: string, opts: SocketIOClient.ConnectOpts): SocketIOClient.Socket; | ||
on(socket: SocketIOClient.Socket, event: "error" | "connect" | "disconnect" | "sync_room_state", fn: (...args: any[]) => void): void; | ||
emit(socket: SocketIOClient.Socket, event: "sync_room_state", ...args: any[]): void; | ||
on(socket: SocketIOClient.Socket, event: "error" | "connect" | "disconnect" | "sync_room_state" | "update_presence", fn: (...args: any[]) => void): void; | ||
emit(socket: SocketIOClient.Socket, event: "sync_room_state" | "update_presence", ...args: any[]): void; | ||
disconnect(socket: SocketIOClient.Socket): void; | ||
}; | ||
export default Sockets; |
@@ -1,3 +0,11 @@ | ||
export declare type KeyValueObject = { | ||
export declare type Obj = { | ||
[key: string]: any; | ||
}; | ||
export interface Room { | ||
reference: string; | ||
id: string; | ||
state?: any; | ||
} | ||
export interface Session { | ||
token: string; | ||
} |
{ | ||
"name": "@roomservice/browser", | ||
"version": "0.3.2-0", | ||
"version": "0.4.0-0", | ||
"main": "dist/index", | ||
@@ -5,0 +5,0 @@ "types": "dist/index", |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
60035
45
950