diff --git a/browser/websocket/deletePage.ts b/browser/websocket/deletePage.ts new file mode 100644 index 0000000..c761fc4 --- /dev/null +++ b/browser/websocket/deletePage.ts @@ -0,0 +1,50 @@ +import { Socket, socketIO, wrap } from "../../deps/socket.ts"; +import { connect, disconnect } from "./socket.ts"; +import { getProjectId, getUserId } from "./id.ts"; +import { pull } from "./pull.ts"; +import { pushWithRetry } from "./_fetch.ts"; + +export interface DeletePageOptions { + socket?: Socket; +} + +/** 指定したページを削除する + * + * @param project 削除したいページのproject + * @param title 削除したいページのタイトル + * @param options 使用したいSocketがあれば指定する + */ +export async function deletePage( + project: string, + title: string, + options?: DeletePageOptions, +): Promise { + const [ + { pageId, commitId: parentId, persistent }, + projectId, + userId, + ] = await Promise.all([ + pull(project, title), + getProjectId(project), + getUserId(), + ]); + + if (!persistent) return; + + const injectedSocket = options?.socket; + const socket = injectedSocket ?? await socketIO(); + await connect(socket); + const { request } = wrap(socket); + try { + await pushWithRetry(request, [{ deleted: true }], { + projectId, + pageId, + parentId, + userId, + project, + title, + }); + } finally { + if (!injectedSocket) await disconnect(socket); + } +} diff --git a/browser/websocket/listen.ts b/browser/websocket/listen.ts new file mode 100644 index 0000000..524c12f --- /dev/null +++ b/browser/websocket/listen.ts @@ -0,0 +1,54 @@ +import { + ProjectUpdatesStreamCommit, + ProjectUpdatesStreamEvent, + Socket, + socketIO, + wrap, +} from "../../deps/socket.ts"; +import { connect, disconnect } from "./socket.ts"; +import { getProjectId } from "./id.ts"; +export type { + ProjectUpdatesStreamCommit, + ProjectUpdatesStreamEvent, +} from "../../deps/socket.ts"; + +export interface ListenStreamOptions { + socket?: Socket; +} + +/** Streamを購読する + * + * @param project 購読したいproject + * @param events 購読したいevent。配列で指定する + * @param options 使用したいSocketがあれば指定する + */ +export async function* listenStream( + project: string, + events: ["commit" | "event", ...("commit" | "event")[]], + options?: ListenStreamOptions, +): AsyncGenerator< + ProjectUpdatesStreamEvent | ProjectUpdatesStreamCommit, + void, + unknown +> { + const projectId = await getProjectId(project); + + const injectedSocket = options?.socket; + const socket = injectedSocket ?? await socketIO(); + await connect(socket); + const { request, response } = wrap(socket); + + try { + // 部屋に入って購読し始める + await request("socket.io-request", { + method: "room:join", + data: { projectId, pageId: null, projectUpdatesStream: true }, + }); + + yield* response( + ...events.map((event) => `projectUpdatesStream:${event}` as const), + ); + } finally { + if (!injectedSocket) await disconnect(socket); + } +} diff --git a/browser/websocket/mod.ts b/browser/websocket/mod.ts index 7959b96..a5932a5 100644 --- a/browser/websocket/mod.ts +++ b/browser/websocket/mod.ts @@ -1,3 +1,5 @@ export * from "./room.ts"; -export * from "./shortcuts.ts"; -export * from "./stream.ts"; +export * from "./patch.ts"; +export * from "./deletePage.ts"; +export * from "./pin.ts"; +export * from "./listen.ts"; diff --git a/browser/websocket/patch.ts b/browser/websocket/patch.ts new file mode 100644 index 0000000..cde51c4 --- /dev/null +++ b/browser/websocket/patch.ts @@ -0,0 +1,93 @@ +import { Socket, socketIO, wrap } from "../../deps/socket.ts"; +import { connect, disconnect } from "./socket.ts"; +import { getProjectId, getUserId } from "./id.ts"; +import { makeChanges } from "./makeChanges.ts"; +import { HeadData, pull } from "./pull.ts"; +import type { Line } from "../../deps/scrapbox.ts"; +import { pushCommit, pushWithRetry } from "./_fetch.ts"; + +export interface PatchOptions { + socket?: Socket; +} + +/** ページ全体を書き換える + * + * serverには書き換え前後の差分だけを送信する + * + * @param project 書き換えたいページのproject + * @param title 書き換えたいページのタイトル + * @param update 書き換え後の本文を作成する函数。引数には現在の本文が渡される。空配列を返すとページが削除される。undefinedを返すと書き換えを中断する + * @param options 使用したいSocketがあれば指定する + */ +export async function patch( + project: string, + title: string, + update: ( + lines: Line[], + metadata: HeadData, + ) => string[] | undefined | Promise, + options?: PatchOptions, +): Promise { + const [ + head_, + projectId, + userId, + ] = await Promise.all([ + pull(project, title), + getProjectId(project), + getUserId(), + ]); + + let head = head_; + + const injectedSocket = options?.socket; + const socket = injectedSocket ?? await socketIO(); + await connect(socket); + try { + const { request } = wrap(socket); + + // 3回retryする + for (let i = 0; i < 3; i++) { + try { + const pending = update(head.lines, head); + const newLines = pending instanceof Promise ? await pending : pending; + + if (!newLines) return; + + if (newLines.length === 0) { + await pushWithRetry(request, [{ deleted: true }], { + projectId, + pageId: head.pageId, + parentId: head.commitId, + userId, + project, + title, + }); + } + + const changes = makeChanges(head.lines, newLines, { userId, head }); + await pushCommit(request, changes, { + parentId: head.commitId, + projectId, + pageId: head.pageId, + userId, + }); + break; + } catch (_e: unknown) { + if (i === 2) { + throw Error("Faild to retry pushing."); + } + console.log( + "Faild to push a commit. Retry after pulling new commits", + ); + try { + head = await pull(project, title); + } catch (e: unknown) { + throw e; + } + } + } + } finally { + if (!injectedSocket) await disconnect(socket); + } +} diff --git a/browser/websocket/pin.ts b/browser/websocket/pin.ts index 138d91e..d9ba8cf 100644 --- a/browser/websocket/pin.ts +++ b/browser/websocket/pin.ts @@ -1,2 +1,115 @@ +import { Socket, socketIO, wrap } from "../../deps/socket.ts"; +import { connect, disconnect } from "./socket.ts"; +import { getProjectId, getUserId } from "./id.ts"; +import { pull } from "./pull.ts"; +import { pushWithRetry } from "./_fetch.ts"; + +export interface PinOptions { + /** ピン留め対象のページが存在しないときの振る舞いを変えるoption + * + * -`true`: タイトルのみのページを作成してピン留めする + * - `false`: ピン留めしない + * + * @default false + */ + create?: boolean; + socket?: Socket; +} +/** 指定したページをピン留めする + * + * @param project ピン留めしたいページのproject + * @param title ピン留めしたいページのタイトル + * @param options 使用したいSocketがあれば指定する + */ +export async function pin( + project: string, + title: string, + options?: PinOptions, +): Promise { + const [ + head, + projectId, + userId, + ] = await Promise.all([ + pull(project, title), + getProjectId(project), + getUserId(), + ]); + + // 既にピン留めされている場合は何もしない + if (head.pin > 0 || (!head.persistent && !(options?.create ?? false))) return; + + const init = { + parentId: head.commitId, + projectId, + pageId: head.pageId, + userId, + project, + title, + }; + const injectedSocket = options?.socket; + const socket = injectedSocket ?? await socketIO(); + await connect(socket); + const { request } = wrap(socket); + + // タイトルのみのページを作る + if (!head.persistent) { + const commitId = await pushWithRetry(request, [{ title }], init); + init.parentId = commitId; + } + + try { + await pushWithRetry(request, [{ pin: pinNumber() }], init); + } finally { + if (!injectedSocket) await disconnect(socket); + } +} + +export interface UnPinOptions { + socket?: Socket; +} +/** 指定したページのピン留めを外す + * + * @param project ピン留めを外したいページのproject + * @param title ピン留めを外したいページのタイトル + */ +export async function unpin( + project: string, + title: string, + options: UnPinOptions, +): Promise { + const [ + head, + projectId, + userId, + ] = await Promise.all([ + pull(project, title), + getProjectId(project), + getUserId(), + ]); + + // 既にピンが外れているか、そもそも存在しないページの場合は何もしない + if (head.pin == 0 || !head.persistent) return; + + const init = { + parentId: head.commitId, + projectId, + pageId: head.pageId, + userId, + project, + title, + }; + const injectedSocket = options?.socket; + const socket = injectedSocket ?? await socketIO(); + await connect(socket); + const { request } = wrap(socket); + + try { + await pushWithRetry(request, [{ pin: 0 }], init); + } finally { + if (!injectedSocket) await disconnect(socket); + } +} + export const pinNumber = (): number => Number.MAX_SAFE_INTEGER - Math.floor(Date.now() / 1000); diff --git a/browser/websocket/shortcuts.ts b/browser/websocket/shortcuts.ts deleted file mode 100644 index 0070336..0000000 --- a/browser/websocket/shortcuts.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { socketIO, wrap } from "../../deps/socket.ts"; -import { getProjectId, getUserId } from "./id.ts"; -import { makeChanges } from "./makeChanges.ts"; -import { HeadData, pull } from "./pull.ts"; -import { pinNumber } from "./pin.ts"; -import type { Line } from "../../deps/scrapbox.ts"; -import { pushCommit, pushWithRetry } from "./_fetch.ts"; - -/** 指定したページを削除する - * - * @param project 削除したいページのproject - * @param title 削除したいページのタイトル - */ -export async function deletePage( - project: string, - title: string, -): Promise { - const [ - { pageId, commitId: parentId, persistent }, - projectId, - userId, - ] = await Promise.all([ - pull(project, title), - getProjectId(project), - getUserId(), - ]); - - if (!persistent) return; - - const io = await socketIO(); - const { request } = wrap(io); - - try { - await pushWithRetry(request, [{ deleted: true }], { - projectId, - pageId, - parentId, - userId, - project, - title, - }); - } finally { - io.disconnect(); - } -} - -/** ページ全体を書き換える - * - * serverには書き換え前後の差分だけを送信する - * - * @param project 書き換えたいページのproject - * @param title 書き換えたいページのタイトル - * @param update 書き換え後の本文を作成する函数。引数には現在の本文が渡される。空配列を返すとページが削除される。undefinedを返すと書き換えを中断する - */ -export async function patch( - project: string, - title: string, - update: ( - lines: Line[], - metadata: HeadData, - ) => string[] | undefined | Promise, -): Promise { - const [ - head_, - projectId, - userId, - ] = await Promise.all([ - pull(project, title), - getProjectId(project), - getUserId(), - ]); - - let head = head_; - - const io = await socketIO(); - try { - const { request } = wrap(io); - - // 3回retryする - for (let i = 0; i < 3; i++) { - try { - const pending = update(head.lines, head); - const newLines = pending instanceof Promise ? await pending : pending; - - if (!newLines) return; - - if (newLines.length === 0) { - await pushWithRetry(request, [{ deleted: true }], { - projectId, - pageId: head.pageId, - parentId: head.commitId, - userId, - project, - title, - }); - } - - const changes = makeChanges(head.lines, newLines, { userId, head }); - await pushCommit(request, changes, { - parentId: head.commitId, - projectId, - pageId: head.pageId, - userId, - }); - break; - } catch (_e: unknown) { - if (i === 2) { - throw Error("Faild to retry pushing."); - } - console.log( - "Faild to push a commit. Retry after pulling new commits", - ); - try { - head = await pull(project, title); - } catch (e: unknown) { - throw e; - } - } - } - } finally { - io.disconnect(); - } -} - -export interface PinOption { - /** ピン留め対象のページが存在しないときの振る舞いを変えるoption - * - * -`true`: タイトルのみのページを作成してピン留めする - * - `false`: ピン留めしない - * - * @default false - */ - create: boolean; -} -/** 指定したページをピン留めする - * - * @param project ピン留めしたいページのproject - * @param title ピン留めしたいページのタイトル - */ -export async function pin( - project: string, - title: string, - option?: PinOption, -): Promise { - const [ - head, - projectId, - userId, - ] = await Promise.all([ - pull(project, title), - getProjectId(project), - getUserId(), - ]); - - // 既にピン留めされている場合は何もしない - if (head.pin > 0 || (!head.persistent && !(option?.create ?? false))) return; - - const init = { - parentId: head.commitId, - projectId, - pageId: head.pageId, - userId, - project, - title, - }; - const io = await socketIO(); - const { request } = wrap(io); - - // タイトルのみのページを作る - if (!head.persistent) { - const commitId = await pushWithRetry(request, [{ title }], init); - init.parentId = commitId; - } - - try { - await pushWithRetry(request, [{ pin: pinNumber() }], init); - } finally { - io.disconnect(); - } -} -/** 指定したページのピン留めを外す - * - * @param project ピン留めを外したいページのproject - * @param title ピン留めを外したいページのタイトル - */ -export async function unpin( - project: string, - title: string, -): Promise { - const [ - head, - projectId, - userId, - ] = await Promise.all([ - pull(project, title), - getProjectId(project), - getUserId(), - ]); - - // 既にピンが外れているか、そもそも存在しないページの場合は何もしない - if (head.pin == 0 || !head.persistent) return; - - const init = { - parentId: head.commitId, - projectId, - pageId: head.pageId, - userId, - project, - title, - }; - const io = await socketIO(); - const { request } = wrap(io); - - try { - await pushWithRetry(request, [{ pin: 0 }], init); - } finally { - io.disconnect(); - } -} diff --git a/browser/websocket/socket.ts b/browser/websocket/socket.ts new file mode 100644 index 0000000..82c9f00 --- /dev/null +++ b/browser/websocket/socket.ts @@ -0,0 +1,38 @@ +import { Socket, socketIO } from "../../deps/socket.ts"; +export type { Socket } from "../../deps/socket.ts"; + +/** 新しいsocketを作る */ +export function makeSocket() { + return socketIO(); +} + +/** websocketに(再)接続する + * + * @param socket 接続したいsocket + */ +export async function connect(socket: Socket): Promise { + if (socket.connected) return; + socket.connect(); + + return await new Promise((resolve) => + socket.once("connect", () => resolve()) + ); +} + +/** websocketを切断する + * + * @param socket 切断したいsocket + */ +export async function disconnect(socket: Socket): Promise { + if (socket.disconnected) return; + socket.disconnect(); + + return await new Promise((resolve) => { + const onDisconnect = (reason: Socket.DisconnectReason) => { + if (reason !== "io client disconnect") return; + resolve(); + socket.off("disconnect", onDisconnect); + }; + socket.on("disconnect", onDisconnect); + }); +} diff --git a/browser/websocket/stream.ts b/browser/websocket/stream.ts deleted file mode 100644 index 7a5cdde..0000000 --- a/browser/websocket/stream.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ListenEventMap, socketIO, wrap } from "../../deps/socket.ts"; -import { getProjectId } from "./id.ts"; -export type { - ProjectUpdatesStreamCommit, - ProjectUpdatesStreamEvent, -} from "../../deps/socket.ts"; - -/** Streamを購読する - * - * @param project 購読したいproject - * @param events 購読したいeventの種類。複数種類を指定できる - */ -export async function* listenStream( - project: string, - ...events: EventName[] -) { - const projectId = await getProjectId(project); - - const io = await socketIO(); - const { request, response } = wrap(io); - await request("socket.io-request", { - method: "room:join", - data: { projectId, pageId: null, projectUpdatesStream: true }, - }); - try { - yield* response( - ...(events.length > 0 ? events : [ - "projectUpdatesStream:event", - "projectUpdatesStream:commit", - ] as const), - ); - } finally { - io.disconnect(); - } -} diff --git a/deps/socket.ts b/deps/socket.ts index e9e8cf3..2bd0b14 100644 --- a/deps/socket.ts +++ b/deps/socket.ts @@ -8,6 +8,7 @@ export type { Pin, ProjectUpdatesStreamCommit, ProjectUpdatesStreamEvent, + Socket, UpdateCommit, } from "https://raw.githubusercontent.com/takker99/scrapbox-userscript-websocket/0.1.4/mod.ts"; export {