diff --git a/jupyter_collaboration/app.py b/jupyter_collaboration/app.py index 946d8556..a28d7b0f 100644 --- a/jupyter_collaboration/app.py +++ b/jupyter_collaboration/app.py @@ -5,7 +5,12 @@ from traitlets import Float, Int, Type from ypy_websocket.ystore import BaseYStore -from .handlers import SQLiteYStore, YDocRoomIdHandler, YDocWebSocketHandler +from .handlers import ( + DocSessionHandler, + SQLiteYStore, + YDocRoomIdHandler, + YDocWebSocketHandler, +) class YDocExtension(ExtensionApp): @@ -57,7 +62,11 @@ def initialize_settings(self): def initialize_handlers(self): self.handlers.extend( [ + # Deprecated - to remove for 1.0.0 (r"/api/yjs/roomid/(.*)", YDocRoomIdHandler), + # Deprecated - to remove for 1.0.0 (r"/api/yjs/(.*)", YDocWebSocketHandler), + (r"/api/collaboration/room/(.*)", YDocWebSocketHandler), + (r"/api/collaboration/session/(.*)", DocSessionHandler), ] ) diff --git a/jupyter_collaboration/handlers.py b/jupyter_collaboration/handlers.py index 78fe4063..3c223cc9 100644 --- a/jupyter_collaboration/handlers.py +++ b/jupyter_collaboration/handlers.py @@ -3,6 +3,7 @@ import asyncio import json +import uuid from logging import Logger from pathlib import Path from typing import Any, Dict, Optional, Set, Tuple @@ -24,6 +25,8 @@ YFILE = YDOCS["file"] +SERVER_SESSION = str(uuid.uuid4()) + class TempFileYStore(_TempFileYStore): prefix_dir = "jupyter_ystore_" @@ -201,6 +204,11 @@ async def open(self, path): self.websocket_server.background_tasks.add(task) task.add_done_callback(self.websocket_server.background_tasks.discard) + # Close the connection if the document session expired + session_id = self.get_query_argument("sessionId", "") + if isinstance(self.room, DocumentRoom) and SERVER_SESSION != session_id: + self.close(1003, f"Document session {session_id} expired") + # cancel the deletion of the room if it was scheduled if isinstance(self.room, DocumentRoom) and self.room.cleaner is not None: self.room.cleaner.cancel() @@ -227,6 +235,7 @@ async def open(self, path): # if YStore updates and source file are out-of-sync, resync updates with source if self.room.document.source != model["content"]: read_from_source = True + if read_from_source: self.room.document.source = model["content"] if self.room.ystore: @@ -409,3 +418,39 @@ async def put(self, path): ws_url += str(idx) self.log.info("Request for Y document '%s' with room ID: %s", path, ws_url) return self.finish(ws_url) + + +class DocSessionHandler(APIHandler): + auth_resource = "contents" + + @web.authenticated + @authorized + async def put(self, path): + body = json.loads(self.request.body) + format = body["format"] + content_type = body["type"] + file_id_manager = self.settings["file_id_manager"] + + idx = file_id_manager.get_id(path) + if idx is not None: + # index already exists + self.log.info("Request for Y document '%s' with room ID: %s", path, idx) + data = json.dumps( + {"format": format, "type": content_type, "fileId": idx, "sessionId": SERVER_SESSION} + ) + self.set_status(200) + return self.finish(data) + + # try indexing + idx = file_id_manager.index(path) + if idx is None: + # file does not exists + raise web.HTTPError(404, f"File {path!r} does not exist") + + # index successfully created + self.log.info("Request for Y document '%s' with room ID: %s", path, idx) + data = json.dumps( + {"format": format, "type": content_type, "fileId": idx, "sessionId": SERVER_SESSION} + ) + self.set_status(201) + return self.finish(data) diff --git a/packages/collaboration-extension/src/filebrowser.ts b/packages/collaboration-extension/src/filebrowser.ts index 60dcfdb4..000ee55d 100644 --- a/packages/collaboration-extension/src/filebrowser.ts +++ b/packages/collaboration-extension/src/filebrowser.ts @@ -9,6 +9,7 @@ import { IDefaultFileBrowser, IFileBrowserFactory } from '@jupyterlab/filebrowser'; +import { ITranslator } from '@jupyterlab/translation'; import { CommandRegistry } from '@lumino/commands'; @@ -27,11 +28,12 @@ namespace CommandIDs { export const defaultFileBrowser: JupyterFrontEndPlugin = { id: '@jupyter/collaboration-extension:defaultFileBrowser', provides: IDefaultFileBrowser, - requires: [IFileBrowserFactory], + requires: [IFileBrowserFactory, ITranslator], optional: [IRouter, JupyterFrontEnd.ITreeResolver, ILabShell], activate: async ( app: JupyterFrontEnd, fileBrowserFactory: IFileBrowserFactory, + translator: ITranslator, router: IRouter | null, tree: JupyterFrontEnd.ITreeResolver | null, labShell: ILabShell | null @@ -41,7 +43,8 @@ export const defaultFileBrowser: JupyterFrontEndPlugin = { ); const { commands } = app; - const drive = new YDrive(app.serviceManager.user); + const trans = translator.load('jupyter_collaboration'); + const drive = new YDrive(app.serviceManager.user, trans); app.serviceManager.contents.addDrive(drive); // Manually restore and load the default file browser. diff --git a/packages/docprovider/src/requests.ts b/packages/docprovider/src/requests.ts new file mode 100644 index 00000000..0bb3d641 --- /dev/null +++ b/packages/docprovider/src/requests.ts @@ -0,0 +1,47 @@ +/* ----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +import { URLExt } from '@jupyterlab/coreutils'; +import { ServerConnection, Contents } from '@jupyterlab/services'; + +/** + * Document session endpoint provided by `jupyter_collaboration` + * See https://github.com/jupyterlab/jupyter_collaboration + */ +const DOC_SESSION_URL = 'api/collaboration/session'; + +export interface ISessionModel { + format: Contents.FileFormat; + type: Contents.ContentType; + fileId: string; + sessionId: string; +} + +export async function requestDocSession( + format: string, + type: string, + path: string +): Promise { + const { makeSettings, makeRequest, ResponseError } = ServerConnection; + + const settings = makeSettings(); + const url = URLExt.join( + settings.baseUrl, + DOC_SESSION_URL, + encodeURIComponent(path) + ); + const data = { + method: 'PUT', + body: JSON.stringify({ format, type }) + }; + + const response = await makeRequest(url, data, settings); + + if (response.status !== 200 && response.status !== 201) { + throw new ResponseError(response); + } + + return response.json(); +} diff --git a/packages/docprovider/src/ydrive.ts b/packages/docprovider/src/ydrive.ts index d42884de..f1cc00e9 100644 --- a/packages/docprovider/src/ydrive.ts +++ b/packages/docprovider/src/ydrive.ts @@ -9,13 +9,14 @@ import { YNotebook } from '@jupyter/ydoc'; import { URLExt } from '@jupyterlab/coreutils'; +import { TranslationBundle } from '@jupyterlab/translation'; import { Contents, Drive, User } from '@jupyterlab/services'; import { WebSocketProvider } from './yprovider'; /** * The url for the default drive service. */ -const Y_DOCUMENT_PROVIDER_URL = 'api/yjs'; +const DOCUMENT_PROVIDER_URL = 'api/collaboration/room'; /** * A Collaborative implementation for an `IDrive`, talking to the @@ -27,9 +28,10 @@ export class YDrive extends Drive { * * @param user - The user manager to add the identity to the awareness of documents. */ - constructor(user: User.IManager) { + constructor(user: User.IManager, translator: TranslationBundle) { super({ name: 'YDrive' }); this._user = user; + this._trans = translator; this._providers = new Map(); this.sharedModelFactory = new SharedModelFactory(this._onCreate); @@ -85,7 +87,7 @@ export class YDrive extends Drive { options?: Contents.IFetchOptions ): Promise { if (options && options.format && options.type) { - const key = `${options.type}:${options.format}:${localPath}`; + const key = `${options.format}:${options.type}:${localPath}`; const provider = this._providers.get(key); if (provider) { @@ -150,15 +152,16 @@ export class YDrive extends Drive { } try { const provider = new WebSocketProvider({ - url: URLExt.join(this.serverSettings.wsUrl, Y_DOCUMENT_PROVIDER_URL), + url: URLExt.join(this.serverSettings.wsUrl, DOCUMENT_PROVIDER_URL), path: options.path, format: options.format, contentType: options.contentType, model: sharedModel, - user: this._user + user: this._user, + translator: this._trans }); - const key = `${options.contentType}:${options.format}:${options.path}`; + const key = `${options.format}:${options.contentType}:${options.path}`; this._providers.set(key, provider); sharedModel.disposed.connect(() => { @@ -178,6 +181,7 @@ export class YDrive extends Drive { }; private _user: User.IManager; + private _trans: TranslationBundle; private _providers: Map; } diff --git a/packages/docprovider/src/yprovider.ts b/packages/docprovider/src/yprovider.ts index 55125e81..52bcf541 100644 --- a/packages/docprovider/src/yprovider.ts +++ b/packages/docprovider/src/yprovider.ts @@ -3,21 +3,20 @@ | Distributed under the terms of the Modified BSD License. |----------------------------------------------------------------------------*/ -import { URLExt } from '@jupyterlab/coreutils'; -import { ServerConnection, User } from '@jupyterlab/services'; -import { DocumentChange, YDocument } from '@jupyter/ydoc'; +import { showErrorMessage, Dialog } from '@jupyterlab/apputils'; +import { User } from '@jupyterlab/services'; +import { TranslationBundle } from '@jupyterlab/translation'; + import { PromiseDelegate } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { Signal } from '@lumino/signaling'; + +import { DocumentChange, YDocument } from '@jupyter/ydoc'; + import { Awareness } from 'y-protocols/awareness'; import { WebsocketProvider as YWebsocketProvider } from 'y-websocket'; -import type { Doc } from 'yjs'; -/** - * Room Id endpoint provided by `jupyter_collaboration` - * See https://github.com/jupyterlab/jupyter_collaboration - */ -const FILE_PATH_TO_ROOM_ID_URL = 'api/yjs/roomid'; +import { ISessionModel, requestDocSession } from './requests'; /** * An interface for a document provider. @@ -47,9 +46,10 @@ export class WebSocketProvider implements IDocumentProvider { this._contentType = options.contentType; this._format = options.format; this._serverUrl = options.url; - this._ydoc = options.model.ydoc; + this._sharedModel = options.model; this._awareness = options.model.awareness; this._yWebsocketProvider = null; + this._trans = options.translator; const user = options.user; @@ -60,35 +60,7 @@ export class WebSocketProvider implements IDocumentProvider { .catch(e => console.error(e)); user.userChanged.connect(this._onUserChanged, this); - const serverSettings = ServerConnection.makeSettings(); - const url = URLExt.join( - serverSettings.baseUrl, - FILE_PATH_TO_ROOM_ID_URL, - encodeURIComponent(this._path) - ); - const data = { - method: 'PUT', - body: JSON.stringify({ format: this._format, type: this._contentType }) - }; - ServerConnection.makeRequest(url, data, serverSettings) - .then(response => { - if (response.status !== 200 && response.status !== 201) { - throw new ServerConnection.ResponseError(response); - } - return response.text(); - }) - .then(roomid => { - this._yWebsocketProvider = new YWebsocketProvider( - this._serverUrl, - roomid, - this._ydoc, - { - awareness: this._awareness - } - ); - }) - .then(() => this._ready.resolve()) - .catch(reason => console.warn(reason)); + this._connect(); } /** @@ -113,14 +85,58 @@ export class WebSocketProvider implements IDocumentProvider { return; } this._isDisposed = true; + this._yWebsocketProvider?.off('connection-close', this._onConnectionClosed); this._yWebsocketProvider?.destroy(); Signal.clearData(this); } + private _connect(): void { + requestDocSession(this._format, this._contentType, this._path) + .then((session: ISessionModel) => { + this._yWebsocketProvider = new YWebsocketProvider( + this._serverUrl, + `${session.format}:${session.type}:${session.fileId}`, + this._sharedModel.ydoc, + { + disableBc: true, + params: { sessionId: session.sessionId }, + awareness: this._awareness + } + ); + + this._yWebsocketProvider.on( + 'connection-close', + this._onConnectionClosed + ); + }) + .then(r => this._ready.resolve()) + .catch(e => console.warn(e)); + } + private _onUserChanged(user: User.IManager): void { this._awareness.setLocalStateField('user', user.identity); } + private _onConnectionClosed = (event: any): void => { + if (event.code === 1003) { + console.error('Document provider closed:', event.reason); + + showErrorMessage( + this._trans.__('Session expired'), + this._trans.__( + 'The document session expired. You need to reload this browser tab.' + ), + [Dialog.okButton({ label: this._trans.__('Reload') })] + ) + .then(r => window.location.reload()) + .catch(e => window.location.reload()); + + // Dispose shared model immediately. Better break the document model, + // than overriding data on disk. + this._sharedModel.dispose(); + } + }; + private _awareness: Awareness; private _contentType: string; private _format: string; @@ -128,8 +144,9 @@ export class WebSocketProvider implements IDocumentProvider { private _path: string; private _ready = new PromiseDelegate(); private _serverUrl: string; - private _ydoc: Doc; + private _sharedModel: YDocument; private _yWebsocketProvider: YWebsocketProvider | null; + private _trans: TranslationBundle; } /** @@ -169,5 +186,10 @@ export namespace WebSocketProvider { * The user data */ user: User.IManager; + + /** + * The jupyterlab translator + */ + translator: TranslationBundle; } }