diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml index 1d5da735..d78aa820 100644 --- a/.github/workflows/dev-ci.yml +++ b/.github/workflows/dev-ci.yml @@ -52,3 +52,31 @@ jobs: node-version: 18 - run: npm ci - run: npm run build + docs: + runs-on: ubuntu-latest + permissions: + id-token: write + deployments: write + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 18 + - name: Install Package Dependencies + run: npm ci + - name: Build SDK + run: npm run build + - name: Build Documentation + run: npm run docs + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-spaces + role-session-name: "${{ github.run_id }}-${{ github.run_number }}" + - name: Upload Documentation + uses: ably/sdk-upload-action@v1 + with: + sourcePath: docs/typedoc/generated + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: typedoc diff --git a/.gitignore b/.gitignore index 2a5c1164..d5382a82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ .vscode coverage .env +docs/typedoc/generated/ # Local Netlify folder .netlify diff --git a/docs/class-definitions.md b/docs/class-definitions.md index dc9069ff..713379e3 100644 --- a/docs/class-definitions.md +++ b/docs/class-definitions.md @@ -327,6 +327,8 @@ The most recent event emitted by [presence](https://ably.com/docs/presence-occup #### PresenceEvent +TODO This type doesn’t exist in the codebase; it's defined inline in SpaceMember + ```ts type PresenceEvent = { name: 'enter' | 'leave' | 'update' | 'present'; @@ -416,6 +418,8 @@ const otherLocations = await space.locations.getOthers() #### Location +TODO this type doesn’t exist in the codebase; `unknown` is used + Represents a location in an application. ```ts @@ -424,6 +428,8 @@ type Location = string | Record | null; #### LocationUpdate +TODO this type doesn’t exist in the codebase; it’s defined inline in LocationEventMap + Represents a change between locations for a given [`SpaceMember`](#spacemember). ```ts diff --git a/docs/typedoc/intro.md b/docs/typedoc/intro.md new file mode 100644 index 00000000..710857ab --- /dev/null +++ b/docs/typedoc/intro.md @@ -0,0 +1,7 @@ +# Ably Spaces SDK + + + +This is the documentation for Ably’s Spaces SDK. Start with the documentation for the SDK’s {@link default | default export}. + +For alternative documentation, see our [documentation website](https://ably.com/docs/spaces). diff --git a/package-lock.json b/package-lock.json index 9850a22d..4e94a9b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.3", "license": "ISC", "dependencies": { - "nanoid": "^4.0.2" + "nanoid": "^4.0.2", + "typedoc": "^0.25.1" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.2.1", @@ -1294,6 +1295,11 @@ "node": ">=8" } }, + "node_modules/ansi-sequence-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz", + "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==" + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1476,8 +1482,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.0.2", @@ -3338,8 +3343,7 @@ "node_modules/jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "node_modules/keyv": { "version": "4.5.2", @@ -3426,6 +3430,11 @@ "node": ">=10" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "node_modules/magic-string": { "version": "0.30.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.3.tgz", @@ -3465,6 +3474,17 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4270,6 +4290,17 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.4.tgz", + "integrity": "sha512-IXCRip2IQzKwxArNNq1S+On4KPML3Yyn8Zzs/xRgcgOWIr8ntIK3IKzjFPfjy/7kt9ZMjc+FItfqHRBg8b6tNQ==", + "dependencies": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -4755,11 +4786,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedoc": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.1.tgz", + "integrity": "sha512-c2ye3YUtGIadxN2O6YwPEXgrZcvhlZ6HlhWZ8jQRNzwLPn2ylhdGqdR8HbyDRyALP8J6lmSANILCkkIdNPFxqA==", + "dependencies": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4982,6 +5054,16 @@ "node": ">=14.0.0" } }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==" + }, + "node_modules/vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5941,6 +6023,11 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true }, + "ansi-sequence-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.1.tgz", + "integrity": "sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==" + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -6069,8 +6156,7 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "base64-js": { "version": "1.0.2", @@ -7466,8 +7552,7 @@ "jsonc-parser": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true + "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" }, "keyv": { "version": "4.5.2", @@ -7533,6 +7618,11 @@ "yallist": "^4.0.0" } }, + "lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, "magic-string": { "version": "0.30.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.3.tgz", @@ -7565,6 +7655,11 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "dev": true }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==" + }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8096,6 +8191,17 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "shiki": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.4.tgz", + "integrity": "sha512-IXCRip2IQzKwxArNNq1S+On4KPML3Yyn8Zzs/xRgcgOWIr8ntIK3IKzjFPfjy/7kt9ZMjc+FItfqHRBg8b6tNQ==", + "requires": { + "ansi-sequence-parser": "^1.1.0", + "jsonc-parser": "^3.2.0", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^8.0.0" + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -8456,11 +8562,39 @@ "is-typed-array": "^1.1.9" } }, + "typedoc": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.1.tgz", + "integrity": "sha512-c2ye3YUtGIadxN2O6YwPEXgrZcvhlZ6HlhWZ8jQRNzwLPn2ylhdGqdR8HbyDRyALP8J6lmSANILCkkIdNPFxqA==", + "requires": { + "lunr": "^2.3.9", + "marked": "^4.3.0", + "minimatch": "^9.0.3", + "shiki": "^0.14.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" }, "ufo": { "version": "1.3.0", @@ -8572,6 +8706,16 @@ } } }, + "vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==" + }, + "vscode-textmate": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz", + "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==" + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index b06ddb0a..65737e6b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "build:cjs": "npx tsc --project tsconfig.cjs.json && cp res/package.cjs.json dist/cjs/package.json", "build:iife": "rm -rf dist/iife && npx tsc --project tsconfig.iife.json && rollup -c", "prepare": "husky install", - "examples:locks": "ts-node ./examples/locks.ts" + "examples:locks": "ts-node ./examples/locks.ts", + "docs": "typedoc" }, "exports": { "import": "./dist/mjs/index.js", @@ -57,7 +58,8 @@ "vitest": "^0.34.3" }, "dependencies": { - "nanoid": "^4.0.2" + "nanoid": "^4.0.2", + "typedoc": "^0.25.1" }, "peerDependencies": { "ably": "^1.2.43" diff --git a/src/Cursors.ts b/src/Cursors.ts index e3318bc2..2f43ccca 100644 --- a/src/Cursors.ts +++ b/src/Cursors.ts @@ -3,12 +3,7 @@ import { Types } from 'ably'; import Space from './Space.js'; import CursorBatching from './CursorBatching.js'; import CursorDispensing from './CursorDispensing.js'; -import EventEmitter, { - InvalidArgumentError, - inspect, - type EventKey, - type EventListener, -} from './utilities/EventEmitter.js'; +import EventEmitter, { InvalidArgumentError, inspect, type EventListener } from './utilities/EventEmitter.js'; import CursorHistory from './CursorHistory.js'; import { CURSOR_UPDATE } from './CursorConstants.js'; @@ -16,12 +11,55 @@ import type { CursorsOptions, CursorUpdate } from './types.js'; import type { RealtimeMessage } from './utilities/types.js'; import { ERR_NOT_ENTERED_SPACE } from './Errors.js'; -type CursorsEventMap = { +/** + * The property names of `CursorsEventMap` are the names of the events emitted by { @link Cursors }. + */ +export interface CursorsEventMap { update: CursorUpdate; -}; +} const CURSORS_CHANNEL_TAG = '::$cursors'; +/** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * The live cursors feature enables you to track the cursors of members within a space in realtime. + * + * Cursor events are emitted whenever a member moves their mouse within a space. In order to optimize the efficiency and frequency of updates, cursor position events are automatically batched. The batching interval may be customized in order to further optimize for increased performance versus the number of events published. + * + * Live cursor updates are not available as part of the [space state](/spaces/space#subscribe) and must be subscribed to using [`space.cursors.subscribe()`](#subscribe). + * + * > **Important** + * > + * > Live cursors are a great way of providing contextual awareness as to what members are looking at within an application. However, too many cursors moving across a page can often be a distraction rather than an enhancement. As such, Ably recommends a maximum of 20 members simultaneously streaming their cursors in a space at any one time for an optimal end-user experience. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * ## Live cursor foundations + * + * The Spaces SDK is built upon existing Ably functionality available in Ably’s Core SDKs. Understanding which core features are used to provide the abstractions in the Spaces SDK enables you to manage space state and build additional functionality into your application. + * + * Live cursors build upon the functionality of the Pub/Sub Channels [presence](https://ably.com/docs/presence-occupancy/presence) feature. + * + * Due to the high frequency at which updates are streamed for cursor movements, live cursors utilizes its own [channel](https://ably.com/docs/channels). The other features of the Spaces SDK, such as avatar stacks, member locations and component locking all share a single channel. For this same reason, cursor position updates are not included in the [space state](/spaces/space) and may only be subscribed to via the {@link Space.cursors | `cursors` } property. + * + * The channel is only created when a member calls `space.cursors.set()`. The live cursors channel object can be accessed through `space.cursors.channel`. To monitor the [underlying state of the cursors channel](https://ably.com/docs/channels#states), the channel object can be accessed through `space.cursors.channel`. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Handles tracking of member cursors within a space. Inherits from [EventEmitter](/docs/usage.md#event-emitters). + * + */ export default class Cursors extends EventEmitter { private readonly cursorBatching: CursorBatching; private readonly cursorDispensing: CursorDispensing; @@ -31,6 +69,7 @@ export default class Cursors extends EventEmitter { public channel?: Types.RealtimeChannelPromise; + /** @internal */ constructor(private space: Space) { super(); @@ -49,6 +88,85 @@ export default class Cursors extends EventEmitter { * * @param {CursorUpdate} cursor * @return {void} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Set the position of a member’s cursor using the `set()` method. A position must contain an X-axis value and a Y-axis value to set the cursor position on a 2D plane. Calling `set()` will emit a cursor event so that other members are informed of the cursor movement in realtime. + * + * A member must have been [entered](/spaces/space#enter) into the space to set their cursor position. + * + * The `set()` method takes the following parameters: + * + * | Parameter | Description | Type | + * |------------|---------------------------------------------------------------------------------------------------------------------|--------| + * | position.x | The position of the member’s cursor on the X-axis. | Number | + * | position.y | The position of the member’s cursor on the Y-axis. | Number | + * | data | An optional arbitrary JSON-serializable object containing additional information about the cursor, such as a color. | Object | + * + * > **Note** + * > + * > The `data` parameter can be used to stream additional information related to a cursor’s movement, such as: + * > + * > - The color that other member’s should display a cursor as. + * > - The ID of an element that a user may be dragging for drag and drop functionality. + * > - Details of any cursor annotations. + * > + * > Be aware that as live cursor updates are batched it is not advisable to publish data unrelated to cursor position in the `data` parameter. Use a [pub/sub channel](https://ably.com/docs/channels) instead. + * + * The following is an example of a member setting their cursor position by adding an event listener to obtain their cursor coordinates and then publishing their position using the `set()` method: + * + * ```javascript + * window.addEventListener('mousemove', ({ clientX, clientY }) => { + * space.cursors.set({ position: { x: clientX, y: clientY }, data: { color: 'red' } }); + * }); + * ``` + * The following is an example payload of a cursor event. Cursor events are uniquely identifiable by the `connectionId` of a cursor. + * + * ```json + * { + * "hd9743gjDc": { + * "connectionId": "hd9743gjDc", + * "clientId": "clemons#142", + * "position": { + * "x": 864, + * "y": 32 + * }, + * "data": { + * "color": "red" + * } + * } + * } + * ``` + * The following are the properties of a cursor event payload: + * + * > **Moved documentation** + * > + * > This documentation has been moved to {@link CursorUpdate} and {@link CursorPosition}. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Set the position of a cursor. If a member has not yet entered the space, this method will error. + * + * A event payload returned contains an object with 2 properties. `position` is an object with 2 required properties, `x` and `y`. These represent the position of the cursor on a 2D plane. A second optional property, `data` can also be passed. This is an object of any shape and is meant for data associated with the cursor movement (like drag or hover calculation results): + * + * ```ts + * type set = (update: { position: CursorPosition, data?: CursorData }) => void; + * ``` + * + * Example usage: + * + * ```ts + * window.addEventListener('mousemove', ({ clientX, clientY }) => { + * space.cursors.set({ position: { x: clientX, y: clientY }, data: { color: "red" } }); + * }); + * ``` + * */ async set(cursor: Pick) { const self = await this.space.members.getSelf(); @@ -90,7 +208,7 @@ export default class Cursors extends EventEmitter { return !this.emitterHasListeners(subscriptions); } - private emitterHasListeners = (emitter: EventEmitter<{}>) => { + private emitterHasListeners = (emitter: EventEmitter) => { const flattenEvents = (obj: Record) => Object.entries(obj) .map((_, v) => v) @@ -104,9 +222,58 @@ export default class Cursors extends EventEmitter { ); }; - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Subscribe to cursor events by registering a listener. Cursor events are emitted whenever a member moves their cursor by calling `set()`. Use the `subscribe()` method on the `cursors` object of a space to receive updates. + * + * > **Note** + * > + * > The rate at which cursor events are published is controlled by the `outboundBatchInterval` property set in the [cursor options](#options) of a space. + * + * The following is an example of subscribing to cursor events: + * + * ```javascript + * space.cursors.subscribe('update', (cursorUpdate) => { + * console.log(cursorUpdate); + * }); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Listen to `CursorUpdate` events. See [EventEmitter](/docs/usage.md#event-emitters) for overloaded usage. + * + * Available events: + * + * - ##### **update** + * + * Emits an event when a new cursor position is set. The argument supplied to the event listener is a [CursorUpdate](#cursorupdate). + * + * ```ts + * space.cursors.subscribe('update', (cursorUpdate: CursorUpdate) => {}); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link CursorsEventMap} type. + */ + subscribe( + eventOrEvents: K | K[], + listener?: EventListener, + ): void; + /** + * Behaves the same as { @link subscribe:WITH_EVENTS | the overload which accepts one or more event names }, but subscribes to _all_ events. + */ + subscribe(listener?: EventListener): void; + subscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.on(listenerOrEvents, listener); @@ -129,9 +296,51 @@ export default class Cursors extends EventEmitter { } } - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Unsubscribe from cursor events to remove previously registered listeners. + * + * The following is an example of removing a listener for cursor update events: + * + * ```javascript + * space.cursors.unsubscribe(`update`, listener); + * ``` + * Or remove all listeners: + * + * ```javascript + * space.cursors.unsubscribe(); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Remove all event listeners, all event listeners for an event, or specific listeners. See [EventEmitter](/docs/usage.md#event-emitters) for detailed usage. + * + * ```ts + * space.cursors.unsubscribe('update'); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link CursorsEventMap} type. + */ + unsubscribe( + eventOrEvents: K | K[], + listener?: EventListener, + ): void; + /** + * Behaves the same as { @link unsubscribe:WITH_EVENTS | the overload which accepts one or more event names }, but subscribes to _all_ events. + */ + unsubscribe(listener?: EventListener): void; + unsubscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.off(listenerOrEvents, listener); @@ -153,6 +362,27 @@ export default class Cursors extends EventEmitter { } } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get the last `CursorUpdate` object for self. + * + * ```ts + * type getSelf = () => Promise; + * ``` + * + * Example: + * + * ```ts + * const selfPosition = await space.cursors.getSelf(); + * ``` + * + */ async getSelf(): Promise { const self = await this.space.members.getSelf(); if (!self) return null; @@ -161,6 +391,27 @@ export default class Cursors extends EventEmitter { return allCursors[self.connectionId]; } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get the last `CursorUpdate` object for everyone else but yourself. + * + * ```ts + * type getOthers = () => Promise>; + * ``` + * + * Example: + * + * ```ts + * const otherPositions = await space.cursors.getOthers(); + * ``` + * + */ async getOthers(): Promise> { const self = await this.space.members.getSelf(); if (!self) return {}; @@ -171,6 +422,111 @@ export default class Cursors extends EventEmitter { return allCursorsFiltered; } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Cursor positions can be retrieved in one-off calls. These are local calls that retrieve the latest position of cursors retained in memory by the SDK. + * + * The following is an example of retrieving a member’s own cursor position: + * + * ```javascript + * const myCursor = await space.cursors.getSelf(); + * ``` + * The following is an example payload returned by `space.cursors.getSelf()`: + * + * ```json + * { + * “clientId”: “DzOBJqgGXzyUBb816Oa6i”, + * “connectionId”: “__UJBKZchX”, + * "position": { + * "x": 864, + * "y": 32 + * } + * } + * ``` + * The following is an example of retrieving the cursor positions for all members other than the member themselves: + * + * ```javascript + * const othersCursors = await space.cursors.getOthers(); + * ``` + * The following is an example payload returned by `space.cursors.getOthers()`: + * + * ```json + * { + * "3ej3q7yZZz": { + * "clientId": "yyXidHatpP3hJpMpXZi8W", + * "connectionId": "3ej3q7yZZz", + * "position": { + * "x": 12, + * "y": 3 + * } + * }, + * "Z7CA3-1vlR": { + * "clientId": "b18mj5B5hm-govdFEYRyb", + * "connectionId": "Z7CA3-1vlR", + * "position": { + * "x": 502, + * "y": 43 + * } + * } + * } + * ``` + * The following is an example of retrieving the cursor positions for all members, including the member themselves. `getAll()` is useful for retrieving the initial position of members’ cursors. + * + * ```javascript + * const allCursors = await space.cursors.getAll(); + * ``` + * The following is an example payload returned by `space.cursors.getAll()`: + * + * ```json + * { + * "3ej3q7yZZz": { + * "clientId": "yyXidHatpP3hJpMpXZi8W", + * "connectionId": "3ej3q7yZZz", + * "position": { + * "x": 12, + * "y": 3 + * } + * }, + * "Z7CA3-1vlR": { + * "clientId": "b18mj5B5hm-govdFEYRyb", + * "connectionId": "Z7CA3-1vlR", + * "position": { + * "x": 502, + * "y": 43 + * } + * }, + * "__UJBKZchX": { + * “clientId”: “DzOBJqgGXzyUBb816Oa6i”, + * “connectionId”: “__UJBKZchX”, + * "position": { + * "x": 864, + * "y": 32 + * } + * } + * } + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get the last `CursorUpdate` object for all the members. + * + * ```ts + * type getAll = () => Promise>; + * ``` + * + * Example: + * + * ```ts + * const allLatestPositions = await space.cursors.getAll(); + * ``` + * + */ async getAll(): Promise> { const channel = this.getChannel(); return await this.cursorHistory.getLastCursorUpdate(channel, this.options.paginationLimit); diff --git a/src/Locations.ts b/src/Locations.ts index f2b57106..4094708b 100644 --- a/src/Locations.ts +++ b/src/Locations.ts @@ -1,9 +1,4 @@ -import EventEmitter, { - InvalidArgumentError, - inspect, - type EventKey, - type EventListener, -} from './utilities/EventEmitter.js'; +import EventEmitter, { InvalidArgumentError, inspect, type EventListener } from './utilities/EventEmitter.js'; import type { SpaceMember } from './types.js'; import type { PresenceMember } from './utilities/types.js'; @@ -11,17 +6,68 @@ import type Space from './Space.js'; import { ERR_NOT_ENTERED_SPACE } from './Errors.js'; import SpaceUpdate from './SpaceUpdate.js'; -type LocationsEventMap = { - update: { member: SpaceMember; currentLocation: unknown; previousLocation: unknown }; -}; +export namespace LocationsEvents { + export interface UpdateEvent { + member: SpaceMember; + /** + * + * The new location of the member. + */ + currentLocation: unknown; + /** + * + * The previous location of the member. + */ + previousLocation: unknown; + } +} + +/** + * The property names of `LocationsEventMap` are the names of the events emitted by { @link Locations }. + */ +export interface LocationsEventMap { + update: LocationsEvents.UpdateEvent; +} +/** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * The member location feature enables you to track where members are within a space, to see which part of your application they’re interacting with. A location could be the form field they have selected, the cell they’re currently editing in a spreadsheet, or the slide they’re viewing within a slide deck. Multiple members can be present in the same location. + * + * Member locations are used to visually display which component other members currently have selected, or are currently active on. Events are emitted whenever a member sets their location, such as when they click on a new cell, or slide. Events are received by members subscribed to location events and the UI component can be highlighted with the active member’s profile data to visually display their location. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * ## Member location foundations + * + * The Spaces SDK is built upon existing Ably functionality available in Ably’s Core SDKs. Understanding which core features are used to provide the abstractions in the Spaces SDK enables you to manage space state and build additional functionality into your application. + * + * Member locations build upon the functionality of the Pub/Sub Channels [presence](https://ably.com/docs/presence-occupancy/presence) feature. Members are entered into the presence set when they [enter the space](/spaces/space#enter). + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Handles the tracking of member locations within a space. Inherits from {@link EventEmitter}. + * + */ export default class Locations extends EventEmitter { private lastLocationUpdate: Record = {}; + /** @internal */ constructor(private space: Space, private presenceUpdate: Space['presenceUpdate']) { super(); } + /** @internal */ async processPresenceMessage(message: PresenceMember) { // Only an update action is currently a valid location update. if (message.action !== 'update') return; @@ -50,6 +96,36 @@ export default class Locations extends EventEmitter { } } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Use the `set()` method to emit a location event in realtime when a member changes their location. This will be received by all location subscribers to inform them of the location change. A `location` can be any JSON-serializable object, such as a slide number or element ID. + * + * A member must have been [entered](/spaces/space#enter) into the space to set their location. + * + * The `set()` method is commonly combined with [`addEventListener()`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener) or a React [synthetic event](https://react.dev/learn/responding-to-events#adding-event-handlers), such as `onClick` or `onHover`. + * + * The following is an example of a member setting their location to a specific slide number, and element on that slide: + * + * ```javascript + * await space.locations.set({ slide: '3', component: 'slide-title' }); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * + * Set your current location. [Location](#location-1) can be any JSON-serializable object. Emits a [locationUpdate](#locationupdate) event to all connected clients in this space. + * + * ```ts + * type set = (update: Location) => Promise; + * ``` + * + */ async set(location: unknown) { const self = await this.space.members.getSelf(); @@ -61,9 +137,101 @@ export default class Locations extends EventEmitter { await this.presenceUpdate(update.updateLocation(location)); } - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Subscribe to location events by registering a listener. Location events are emitted whenever a member changes location by calling {@link set}. + * + * All location changes are {@link LocationsEventMap.update | `update`} events. When a location update is received, clear the highlight from the UI component of the member’s {@link LocationsEvents.UpdateEvent.previousLocation | `previousLocation`} and add it to {@link LocationsEvents.UpdateEvent.currentLocation | `currentLocation`}. + * + * > **Note** + * > + * > A location update is also emitted when a member {@link Space.leave | leaves} a space. The member’s {@link LocationsEvents.UpdateEvent.currentLocation | `currentLocation` } will be `null` for these events so that any UI component highlighting can be cleared. + * + * The following is an example of subscribing to location events: + * + * ```javascript + * space.locations.subscribe('update', (locationUpdate) => { + * console.log(locationUpdate); + * }); + * ``` + * The following is an example payload of a location event. Information about location is returned in {@link LocationsEvents.UpdateEvent.currentLocation | `currentLocation`} and {@link LocationsEvents.UpdateEvent.previousLocation | `previousLocation`}: + * + * ```json + * { + * "member": { + * "clientId": "clemons#142", + * "connectionId": "hd9743gjDc", + * "isConnected": true, + * "profileData": { + * "username": "Claire Lemons", + * "avatar": "https://slides-internal.com/users/clemons.png" + * }, + * "location": { + * "slide": "3", + * "component": "slide-title" + * }, + * "lastEvent": { + * "name": "update", + * "timestamp": 1972395669758 + * } + * }, + * "previousLocation": { + * "slide": "2", + * "component": null + * }, + * "currentLocation": { + * "slide": "3", + * "component": "slide-title" + * } + * } + * ``` + * The following are the properties of a location event payload: + * + * > **Moved documentation** + * > + * > This documentation has been moved to { @link LocationsEvents.UpdateEvent }. + * + * > **Further reading** + * > + * > Member location subscription listeners only trigger on events related to members’ locations. Each event only contains the payload of the member that triggered it. Alternatively, [space state](/spaces/space) can be subscribed to which returns an array of all members with their latest state every time any event is triggered. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Listen to events for locations. See {@link EventEmitter} for overloaded usage. + * + * Available events: + * + * - ##### **update** + * + * Fires when a member updates their location. The argument supplied to the event listener is a [LocationUpdate](#locationupdate-1). + * + * ```ts + * space.locations.subscribe('update', (locationUpdate: LocationUpdate) => {}); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link LocationsEventMap} type. + */ + subscribe( + eventOrEvents: K | K[], + listener?: EventListener, + ): void; + /** + * Behaves the same as { @link subscribe:WITH_EVENTS | the overload which accepts one or more event names }, but subscribes to _all_ events. + */ + subscribe(listener?: EventListener): void; + subscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.on(listenerOrEvents, listener); @@ -78,9 +246,51 @@ export default class Locations extends EventEmitter { } } - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Unsubscribe from location events to remove previously registered listeners. + * + * The following is an example of removing a listener for location update events: + * + * ```javascript + * space.locations.unsubscribe('update', listener); + * ``` + * Or remove all listeners: + * + * ```javascript + * space.locations.unsubscribe(); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Remove all event listeners, all event listeners for an event, or specific listeners. See {@link EventEmitter} for detailed usage. + * + * ```ts + * space.locations.unsubscribe('update'); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link LocationsEventMap} type. + */ + unsubscribe( + eventOrEvents: K | K[], + listener?: EventListener, + ): void; + /** + * Behaves the same as { @link unsubscribe:WITH_EVENTS | the overload which accepts one or more event names }, but unsubscribes from _all_ events. + */ + unsubscribe(listener?: EventListener): void; + unsubscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.off(listenerOrEvents, listener); @@ -95,11 +305,53 @@ export default class Locations extends EventEmitter { } } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get location for self. + * + * ```ts + * type getSelf = () => Promise; + * ``` + * + * Example: + * + * ```ts + * const myLocation = await space.locations.getSelf(); + * ``` + * + */ async getSelf(): Promise { const self = await this.space.members.getSelf(); return self ? self.location : null; } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get location for other members + * + * ```ts + * type getOthers = () => Promise>; + * ``` + * + * Example: + * + * ```ts + * const otherLocations = await space.locations.getOthers() + * ``` + * + */ async getOthers(): Promise> { const members = await this.space.members.getOthers(); @@ -109,6 +361,87 @@ export default class Locations extends EventEmitter { }, {}); } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Member locations can also be retrieved in one-off calls. These are local calls and retrieve the location of members retained in memory by the SDK. + * + * The following is an example of retrieving a member’s own location: + * + * ```javascript + * const myLocation = await space.locations.getSelf(); + * ``` + * The following is an example payload returned by `space.locations.getSelf()`. It will return the properties of the member’s `location`: + * + * ```json + * { + * "slide": "3", + * "component": "slide-title" + * } + * ``` + * The following is an example of retrieving the location objects of all members other than the member themselves. + * + * ```javascript + * const othersLocations = await space.locations.getOthers(); + * ``` + * The following is an example payload returned by `space.locations.getOthers()`: It will return the properties of all member’s `location` by their `connectionId`: + * + * ```json + * { + * "xG6H3lnrCn": { + * "slide": "1", + * "component": "textBox-1" + * }, + * "el29SVLktW": { + * "slide": "1", + * "component": "image-2" + * } + * } + * ``` + * The following is an example of retrieving the location objects of all members, including the member themselves: + * + * ```javascript + * const allLocations = await space.locations.getAll(); + * ``` + * The following is an example payload returned by `space.locations.getAll()`. It will return the properties of all member’s `location` by their `connectionId`: + * + * ```json + * { + * "xG6H3lnrCn": { + * "slide": "1", + * "component": "textBox-1" + * }, + * "el29SVLktW": { + * "slide": "1", + * "component": "image-2" + * }, + * "dieF3291kT": { + * "slide": "3", + * "component": "slide-title" + * } + * } + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get location for all members. + * + * ```ts + * type getAll = () => Promise>; + * ``` + * + * Example: + * + * ```ts + * const allLocations = await space.locations.getAll(); + * ``` + * + */ async getAll(): Promise> { const members = await this.space.members.getAll(); return members.reduce((acc: Record, member: SpaceMember) => { diff --git a/src/Locks.ts b/src/Locks.ts index 8e6eba2f..7c335b91 100644 --- a/src/Locks.ts +++ b/src/Locks.ts @@ -4,30 +4,84 @@ import Space from './Space.js'; import type { Lock, SpaceMember } from './types.js'; import type { PresenceMember } from './utilities/types.js'; import { ERR_LOCK_IS_LOCKED, ERR_LOCK_INVALIDATED, ERR_LOCK_REQUEST_EXISTS, ERR_NOT_ENTERED_SPACE } from './Errors.js'; -import EventEmitter, { - InvalidArgumentError, - inspect, - type EventKey, - type EventListener, -} from './utilities/EventEmitter.js'; +import EventEmitter, { InvalidArgumentError, inspect, type EventListener } from './utilities/EventEmitter.js'; import SpaceUpdate from './SpaceUpdate.js'; +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Additional attributes that can be set when acquiring a lock. + * + * ```ts + * type LockAttributes = Map; + * ``` + * + */ export class LockAttributes extends Map { toJSON() { return Object.fromEntries(this); } } -interface LockOptions { +export interface LockOptions { attributes: LockAttributes; } -type LockEventMap = { +/** + * The property names of `LocksEventMap` are the names of the events emitted by { @link Locks }. + */ +export interface LocksEventMap { update: Lock; -}; +} -export default class Locks extends EventEmitter { +/** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * The component locking feature enables members to optimistically lock stateful UI components before editing them. This reduces the chances of conflicting changes being made to the same component by different members. A component could be a cell in a spreadsheet that a member is updating, or an input field on a form they’re filling in. + * + * Once a lock has been acquired by a member, the component that it relates to can be updated in the UI to visually indicate to other members that it is locked and and which member has the lock. The component can then be updated once the editing member has released the lock to indicate that it is now unlocked. + * + * Each lock is identified by a unique string ID, and only a single member may hold a lock with a given string at any one time. A lock will exist in one of three [states](#states) and may only transition between states in specific circumstances. + * + * > **Important** + * > + * > Optimistic locking means that there is a chance that two members may begin editing the same UI component before it is confirmed which member holds the lock. On average, the time taken to reconcile which member holds a lock is in the hundreds of milliseconds. Your application needs to handle the member that successfully obtained the lock, as well as the member that had their request invalidated. + * + * ## Lock states + * + * Component locking is handled entirely client-side. Members may begin to optimistically edit a component as soon as they call [`acquire()`](#acquire) on the lock identifier related to it. Alternatively, you could wait until they receive a `locked` event and display a spinning symbol in the UI until this is received. In either case a subsequent `unlocked` event may invalidate that member’s lock request if another member acquired it earlier. The time for confirmation of whether a lock request was successful or rejected is, on average, in the hundreds of milliseconds, however your code should handle all possible lock state transitions. + * + * A lock will be in one of the following states: + * + * > **Moved documentation** + * > + * > This documentation has been moved to { @link LockStatuses }. + * + * The following lock state transitions may occur: + * + * - None → `pending`: a member calls [`acquire()`](#acquire) to request a lock. + * - `pending` → `locked`: the requesting member holds the lock. + * - `pending` → `unlocked`: the requesting member does not hold the lock, since another member already holds it. + * - `locked` → `unlocked`: the lock was either explicitly [released](#release) by the member, or their request was invalidated by a concurrent request which took precedence. + * - `unlocked` → `locked`: the requesting member reacquired a lock they previously held. + * + * Only transitions that result in a `locked` or `unlocked` status will emit a lock event that members can [`subscribe()`](#subscribe) to. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Provides a mechanism to "lock" a component, reducing the chances of conflict in an application whilst being edited by multiple members. Inherits from [EventEmitter](/docs/usage.md#event-emitters). + * + */ +export default class Locks extends EventEmitter { // locks tracks the local state of locks, which is used to determine whether // a lock's status has changed when processing presence updates. // @@ -36,11 +90,57 @@ export default class Locks extends EventEmitter { // have requested. private locks: Map>; + /** @internal */ constructor(private space: Space, private presenceUpdate: Space['presenceUpdate']) { super(); this.locks = new Map(); } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Use the `get()` method to query whether a lock is currently locked, and by which member if it is. The lock is identifiable by its unique string ID. + * + * The following is an example of checking whether a lock identifier is currently locked: + * + * ```javascript + * const isLocked = space.locks.get(id) !== undefined; + * ``` + * The following is an example of checking which member holds the lock: + * + * ```javascript + * const { member } = space.locks.get(id); + * ``` + * The following is an example of viewing the attributes assigned to the lock by the member holding it: + * + * ```javascript + * const { request } = space.locks.get(id); + * const viewLock = request.attributes.get(key); + * ``` + * If the lock is not currently held by a member, `get()` will return `undefined`. Otherwise it will return the most recent lock event for the lock. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get a lock by its id. + * + * ```ts + * type get = (lockId: string) => Lock | undefined + * ``` + * + * Example: + * + * ```ts + * const id = "/slide/1/element/3"; + * const lock = space.locks.get(id); + * ``` + * + */ get(id: string): Lock | undefined { const locks = this.locks.get(id); if (!locks) return; @@ -53,6 +153,81 @@ export default class Locks extends EventEmitter { // This will be async in the future, when pending requests are no longer processed // in the library. + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Locks can also be retrieved in one-off calls. These are local calls and retrieve the locks retained in memory by the SDK. + * + * The following is an example of retrieving an array of all currently held locks in a space: + * + * ```javascript + * const allLocks = await space.locks.getAll(); + * ``` + * The following is an example payload returned by `space.locks.getAll()`: + * + * ```json + * [ + * { + * "id": "s1-c2", + * "status": "locked", + * "timestamp": 1247525627533, + * "member": { + * "clientId": "amint#5", + * "connectionId": "hg35a4fgjAs", + * "isConnected": true, + * "lastEvent": { + * "name": "update", + * "timestamp": 173459567340 + * }, + * "location": null, + * "profileData": { + * "username": "Arit Mint", + * "avatar": "https://slides-internal.com/users/amint.png" + * } + * } + * }, + * { + * "id": "s3-c4", + * "status": "locked", + * "timestamp": 1247115627423, + * "member": { + * "clientId": "torange#1", + * "connectionId": "tt7233ghUa", + * "isConnected": true, + * "lastEvent": { + * "name": "update", + * "timestamp": 167759566354 + * }, + * "location": null, + * "profileData": { + * "username": "Tara Orange", + * "avatar": "https://slides-internal.com/users/torange.png" + * } + * } + * } + * ] + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get all locks that have the `locked` status. + * + * ```ts + * type getAll = () => Promise + * ``` + * + * Example: + * + * ```ts + * const locks = await space.locks.getAll(); + * ``` + * + */ async getAll(): Promise { const allLocks: Lock[] = []; @@ -67,6 +242,27 @@ export default class Locks extends EventEmitter { return allLocks; } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get all locks belonging to self that have the `locked` status. + * + * ```ts + * type getSelf = () => Promise + * ``` + * + * Example: + * + * ```ts + * const locks = await space.locks.getSelf(); + * ``` + * + */ async getSelf(): Promise { const self = await this.space.members.getSelf(); @@ -75,6 +271,27 @@ export default class Locks extends EventEmitter { return this.getLocksForConnectionId(self.connectionId).filter((lock) => lock.status === 'locked'); } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get all locks belonging to all members except self that have the `locked` status. + * + * ```ts + * type getOthers = () => Promise + * ``` + * + * Example: + * + * ```ts + * const locks = await space.locks.getOthers(); + * ``` + * + */ async getOthers(): Promise { const self = await this.space.members.getSelf(); const allLocks = await this.getAll(); @@ -84,6 +301,63 @@ export default class Locks extends EventEmitter { return allLocks.filter((lock) => lock.member.connectionId !== self.connectionId); } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Use the `acquire()` method to attempt to acquire a lock with a given unique ID. Additional `attributes` may be passed when trying to acquire a lock that can contain a set of arbitrary key-value pairs. An example of using `attributes` is to store the component ID the lock relates to so that it can be easily updated in the UI with a visual indication of its lock status. + * + * A member must have been [entered](/spaces/space#enter) into the space to acquire a lock. + * + * The following is an example of attempting to acquire a lock: + * + * ```javascript + * const acquireLock = await space.locks.acquire(id); + * ``` + * The following is an example of passing a set of `attributes` when trying to acquire a lock: + * + * ```javascript + * const lockAttributes = new Map(); + * lockAttributes.set('component', 'cell-d3'); + * const acquireLock = await space.locks.acquire(id, { lockAttributes }); + * ``` + * The following is an example payload returned by `space.locks.acquire()`. The promise will resolve to a lock request with the `pending` status: + * + * ```json + * { + * "id": "s2-d14", + * "status": "pending", + * "timestamp": 1247525689781, + * "attributes": { + * "componentId": "cell-d14" + * } + * } + * ``` + * Once a member requests a lock by calling `acquire()`, the lock is temporarily in the [pending state](#states). An event will be emitted based on whether the lock request was successful (a status of `locked`) or invalidated (a status of `unlocked`). This can be [subscribed](#subscribe) to in order for the client to know whether their lock request was successful or not. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Send a request to acquire a lock. Returns a Promise which resolves once the request has been sent. A resolved Promise holds a `pending` [Lock](#lock). An error will be thrown if a lock request with a status of `pending` or `locked` already exists, returning a rejected promise. + * + * When a lock acquisition by a member is confirmed with the `locked` status, an `update` event will be emitted. Hence to handle lock acquisition, `acquire()` needs to always be used together with `subscribe()`. + * + * ```ts + * type acquire = (lockId: string) => Promise; + * ``` + * + * Example: + * + * ```ts + * const id = "/slide/1/element/3"; + * const lockRequest = await space.locks.acquire(id); + * ``` + * + */ async acquire(id: string, opts?: LockOptions): Promise { const self = await this.space.members.getSelf(); if (!self) { @@ -117,6 +391,44 @@ export default class Locks extends EventEmitter { return lock; } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Use the `release()` method to explicitly release a lock once a member has finished editing the related component. For example, the `release()` method can be called once a user clicks outside of the component, such as clicking on another cell within a spreadsheet. Any UI indications that the previous cell was locked can then be cleared. + * + * The following is an example of releasing a lock: + * + * ```javascript + * await space.locks.release(id); + * ``` + * Releasing a lock will emit a lock event with a [lock status](#states) of `unlocked`. + * + * > **Note** + * > + * > When a member [leaves](/spaces/space#leave) a space, their locks are automatically released. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Releases a previously requested lock. + * + * ```ts + * type release = (lockId: string) => Promise; + * ``` + * + * Example: + * + * ```ts + * const id = "/slide/1/element/3"; + * await space.locks.release(id); + * ``` + * + */ async release(id: string): Promise { const self = await this.space.members.getSelf(); @@ -135,9 +447,94 @@ export default class Locks extends EventEmitter { this.deleteLock(id, self.connectionId); } - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Subscribe to lock events by registering a listener. Lock events are emitted whenever the [lock state](#states) transitions into `locked` or `unlocked`. + * + * All lock events are `update` events. When a lock event is received, UI components can be updated to add and remove visual indications of which member is locking them, as well as enabling and disabling the ability for other members to edit them. + * + * The following is an example of subscribing to lock events: + * + * ```javascript + * space.locks.subscribe('update', (lock) => { + * console.log(lock); + * }); + * ``` + * The following is an example payload of a lock event: + * + * ```json + * { + * "id": "s2-d14", + * "status": "unlocked", + * "timestamp": 1247525689781, + * "attributes": { + * "componentId": "cell-d14" + * }, + * "reason": { + * "message": "lock is currently locked", + * "code": 101003, + * "statusCode": 400 + * }, + * "member": { + * "clientId": "smango", + * "connectionId": "hs343gjsdc", + * "isConnected": true, + * "profileData": { + * "username": "Saiorse Mango" + * }, + * "location": { + * "slide": "sheet-2", + * "component": "d-14" + * }, + * "lastEvent": { + * "name": "update", + * "timestamp": 1247525689781 + * } + * } + * } + * ``` + * The following are the properties of a lock event payload: + * + * > **Moved documentation** + * > + * > This documentation has been moved to { @link Lock }. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Listen to lock events. See [EventEmitter](/docs/usage.md#event-emitters) for overloaded usage. + * + * Available events: + * + * - ##### **update** + * + * Listen to changes to locks. + * + * ```ts + * space.locks.subscribe('update', (lock: Lock) => {}) + * ``` + * + * The argument supplied to the callback is a [Lock](#lock), representing the lock request and it's status. + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link LocksEventMap} type. + */ + subscribe(eventOrEvents: K | K[], listener?: EventListener): void; + /** + * Behaves the same as { @link subscribe:WITH_EVENTS | the overload which accepts one or more event names }, but subscribes to _all_ events. + */ + subscribe(listener?: EventListener): void; + subscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.on(listenerOrEvents, listener); @@ -152,9 +549,48 @@ export default class Locks extends EventEmitter { } } - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Unsubscribe from lock events to remove previously registered listeners. + * + * The following is an example of removing a listener for lock update events: + * + * ```javascript + * space.locks.unsubscribe('update', listener); + * ``` + * Or remove all listeners: + * + * ```javascript + * space.locks.unsubscribe(); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Remove all event listeners, all event listeners for an event, or specific listeners. See [EventEmitter](/docs/usage.md#event-emitters) for detailed usage. + * + * ```ts + * space.locks.unsubscribe('update'); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link LocksEventMap} type. + */ + unsubscribe(eventOrEvents: K | K[], listener?: EventListener): void; + /** + * Behaves the same as { @link unsubscribe:WITH_EVENTS | the overload which accepts one or more event names }, but unsubscribes from _all_ events. + */ + unsubscribe(listener?: EventListener): void; + unsubscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.off(listenerOrEvents, listener); @@ -169,6 +605,7 @@ export default class Locks extends EventEmitter { } } + /** @internal */ async processPresenceMessage(message: Types.PresenceMessage) { const member = await this.space.members.getByConnectionId(message.connectionId); if (!member) return; @@ -222,7 +659,7 @@ export default class Locks extends EventEmitter { // // TODO: remove this once the Ably system processes PENDING requests // internally using this same logic. - processPending(member: SpaceMember, pendingLock: Lock) { + private processPending(member: SpaceMember, pendingLock: Lock) { // if the requested lock ID is not currently locked, then mark the PENDING // lock request as LOCKED const lock = this.get(pendingLock.id); @@ -266,18 +703,19 @@ export default class Locks extends EventEmitter { pendingLock.reason = ERR_LOCK_IS_LOCKED(); } - updatePresence(self: SpaceMember) { + private updatePresence(self: SpaceMember) { const update = new SpaceUpdate({ self, extras: this.getLockExtras(self.connectionId) }); return this.presenceUpdate(update.noop()); } + /** @internal */ getLock(id: string, connectionId: string): Lock | undefined { const locks = this.locks.get(id); if (!locks) return; return locks.get(connectionId); } - setLock(lock: Lock) { + private setLock(lock: Lock) { let locks = this.locks.get(lock.id); if (!locks) { locks = new Map(); @@ -286,7 +724,7 @@ export default class Locks extends EventEmitter { locks.set(lock.member.connectionId, lock); } - deleteLock(id: string, connectionId: string) { + private deleteLock(id: string, connectionId: string) { const locks = this.locks.get(id); if (!locks) return; return locks.delete(connectionId); @@ -306,6 +744,7 @@ export default class Locks extends EventEmitter { return requests; } + /** @internal */ getLockExtras(connectionId: string): PresenceMember['extras'] { const locks = this.getLocksForConnectionId(connectionId); if (locks.length === 0) return; diff --git a/src/Members.ts b/src/Members.ts index 2edae1f3..aa410def 100644 --- a/src/Members.ts +++ b/src/Members.ts @@ -1,32 +1,90 @@ -import EventEmitter, { - InvalidArgumentError, - inspect, - type EventKey, - type EventListener, -} from './utilities/EventEmitter.js'; +import EventEmitter, { InvalidArgumentError, inspect, type EventListener } from './utilities/EventEmitter.js'; import Leavers from './Leavers.js'; import type { SpaceMember } from './types.js'; import type { PresenceMember } from './utilities/types.js'; import type Space from './Space.js'; -type MemberEventsMap = { +/** + * The property names of `MembersEventMap` are the names of the events emitted by { @link Members }. + */ +export interface MembersEventMap { + /** + * + * A member has left the space. The member has either left explicitly by calling { @link Space.leave | `space.leave()` }, or has abruptly disconnected and not re-established a connection within 15 seconds. + */ leave: SpaceMember; + /** + * + * A new member has entered the space. The member has either entered explicitly by calling {@link Space.enter | `space.enter()` }, or has attempted to update their profile data before entering a space, which will instead emit an `enter` event. + */ enter: SpaceMember; + /** + * + * A member has updated their profile data by calling { @link Space.updateProfileData | `space.updateProfileData()` }. + */ update: SpaceMember; updateProfile: SpaceMember; + /** + * + * A member has been removed from the members list after the { @link SpaceOptions.offlineTimeout | `offlineTimeout` } period has elapsed. This enables members to appear greyed out in the avatar stack to indicate that they recently left for the period of time between their `leave` and `remove` events. + */ remove: SpaceMember; -}; +} -class Members extends EventEmitter { +/** + * > **Documentation source** + * > + * > The following documentation is copied from the Spaces documentation website. + * + * Avatar stacks are the most common way of showing the online status of members in an application by displaying an avatar for each member. Events are emitted whenever a member enters or leaves a space, or updates their profile data. Additional information can also be provided, such as a profile picture and email address. + * + * Subscribe to the space’s { @link Space.members | `members` } property in order to keep your avatar stack updated in realtime. + * + * ## Event types + * + * The following four event types are emitted by members: + * + * > **Moved documentation** + * > + * > This documentation has been moved to { @link MembersEventMap }. + * + * > **Note** + * > + * > Members [enter](/spaces/space#enter), [leave](/spaces/space#leave), and [update](/spaces/space#update-profile) a [space](/spaces/space) directly. The space’s { @link Space.members | `members` } property is used to subscribe to these updates. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from the Spaces documentation website. + * + * ## Avatar stack foundations + * + * The Spaces SDK is built upon existing Ably functionality available in Ably’s Core SDKs. Understanding which core features are used to provide the abstractions in the Spaces SDK enables you to manage space state and build additional functionality into your application. + * + * Avatar stacks build upon the functionality of the Pub/Sub Channels [presence](https://ably.com/docs/presence-occupancy/presence) feature. Members are entered into the presence set when they [enter the space](/spaces/space#enter). + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Handles members within a space. + * + */ +class Members extends EventEmitter { private lastMemberUpdate: Record = {}; private leavers: Leavers; + /** @internal */ constructor(private space: Space) { super(); this.leavers = new Leavers(this.space.options.offlineTimeout); } + /** @internal */ async processPresenceMessage(message: PresenceMember) { const { action, connectionId } = message; const isLeaver = !!this.leavers.getByConnectionId(connectionId); @@ -54,24 +112,364 @@ class Members extends EventEmitter { } } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Returns a Promise which resolves to the [SpaceMember](#spacemember) object relating to the local connection. Will resolve to `null` if the client hasn't entered the space yet. + * + * ```ts + * type getSelf = () => Promise; + * ``` + * + * Example: + * + * ```ts + * const myMember = await space.members.getSelf(); + * ``` + * + */ async getSelf(): Promise { return this.space.connectionId ? await this.getByConnectionId(this.space.connectionId) : null; } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the Spaces documentation website. + * + * Space membership can be retrieved in one-off calls. These are local calls and retrieve the membership retained in memory by the SDK. One-off calls to retrieve membership can be used for operations such as displaying a member’s own profile data to them, or retrieving a list of all other members to use to [update their profile data](/spaces/space#update-profile). + * + * The following is an example of retrieving a member’s own member object: + * + * ```javascript + * const myMemberInfo = await space.members.getSelf(); + * ``` + * The following is an example payload returned by `space.members.getSelf()`: + * + * ```json + * { + * "clientId": "clemons#142", + * "connectionId": "hd9743gjDc", + * "isConnected": true, + * "lastEvent": { + * "name": "enter", + * "timestamp": 1677595689759 + * }, + * "location": null, + * "profileData": { + * "username": "Claire Lemons", + * "avatar": "https://slides-internal.com/users/clemons.png" + * } + * } + * ``` + * The following is an example of retrieving an array of member objects for all members other than the member themselves. Ths includes members that have recently left the space, but have not yet been removed. + * + * ```javascript + * const othersMemberInfo = await space.members.getOthers(); + * ``` + * The following is an example payload returned by `space.members.getOthers()`: + * + * ```json + * [ + * { + * "clientId": "torange#1", + * "connectionId": "tt7233ghUa", + * "isConnected": true, + * "lastEvent": { + * "name": "enter", + * "timestamp": 167759566354 + * }, + * "location": null, + * "profileData": { + * "username": "Tara Orange", + * "avatar": "https://slides-internal.com/users/torange.png" + * } + * }, + * { + * "clientId": "amint#5", + * "connectionId": "hg35a4fgjAs", + * "isConnected": true, + * "lastEvent": { + * "name": "update", + * "timestamp": 173459567340 + * }, + * "location": null, + * "profileData": { + * "username": "Arit Mint", + * "avatar": "https://slides-internal.com/users/amint.png" + * } + * } + * ] + * ``` + * The following is an example of retrieving an array of all member objects, including the member themselves. Ths includes members that have recently left the space, but have not yet been removed. + * + * ```javascript + * const allMembers = await space.members.getAll(); + * ``` + * The following is an example payload returned by `space.members.getAll()`: + * + * ```json + * [ + * { + * "clientId": "clemons#142", + * "connectionId": "hd9743gjDc", + * "isConnected": false, + * "lastEvent": { + * "name": "enter", + * "timestamp": 1677595689759 + * }, + * "location": null, + * "profileData": { + * "username": "Claire Lemons", + * "avatar": "https://slides-internal.com/users/clemons.png" + * } + * }, + * { + * "clientId": "amint#5", + * "connectionId": "hg35a4fgjAs", + * "isConnected": true, + * "lastEvent": { + * "name": "update", + * "timestamp": 173459567340 + * }, + * "location": null, + * "profileData": { + * "username": "Arit Mint", + * "avatar": "https://slides-internal.com/users/amint.png" + * } + * }, + * { + * "clientId": "torange#1", + * "connectionId": "tt7233ghUa", + * "isConnected": true, + * "lastEvent": { + * "name": "enter", + * "timestamp": 167759566354 + * }, + * "location": null, + * "profileData": { + * "username": "Tara Orange", + * "avatar": "https://slides-internal.com/users/torange.png" + * } + * } + * ] + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Returns a Promise which resolves to an array of all [SpaceMember](#spacemember) objects (members) currently in the space, including any who have left and not yet timed out. (_see: [offlineTimeout](#spaceoptions)_) + * + * ```ts + * type getAll = () => Promise; + * ``` + * + * Example: + * + * ```ts + * const allMembers = await space.members.getAll(); + * ``` + * + */ async getAll(): Promise { const presenceMembers = await this.space.channel.presence.get(); const members = presenceMembers.map((m) => this.createMember(m)); return members.concat(this.leavers.getAll().map((l) => l.member)); } + /** + * + * See the documentation for {@link getAll}. + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Returns a Promise which resolves to an array of all [SpaceMember](#spacemember) objects (members) currently in the space, excluding your own member object. + * + * ```ts + * type getSelf = () => Promise; + * ``` + * + * Example: + * + * ```ts + * const otherMembers = await space.members.getOthers(); + * ``` + * + */ async getOthers(): Promise { const members = await this.getAll(); return members.filter((m) => m.connectionId !== this.space.connectionId); } - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the Spaces documentation website. + * + * Subscribe to members’ online status and profile updates by registering a listener. Member events are emitted whenever a member [enters](/spaces/space#enter) or [leaves](/spaces/space#leave) the space, or updates their profile data. Use the `subscribe()` method on the `members` object of a space to receive updates. + * + * The following is an example of subscribing to the different member event types: + * + * ```javascript + * // Subscribe to member enters in a space + * space.members.subscribe('enter', (memberUpdate) => { + * console.log(memberUpdate); + * }); + * + * // Subscribe to member profile data updates in a space + * space.members.subscribe('update', (memberUpdate) => { + * console.log(memberUpdate); + * }); + * + * // Subscribe to member leaves in a space + * space.members.subscribe('leave', (memberUpdate) => { + * console.log(memberUpdate); + * }); + * + * // Subscribe to member removals in a space + * space.members.subscribe('remove', (memberUpdate) => { + * console.log(memberUpdate); + * }); + * ``` + * It’s also possible to subscribe to multiple event types with the same listener by using an array: + * + * ```javascript + * space.members.subscribe(['enter', 'update'], (memberUpdate) => { + * console.log(memberUpdate); + * }); + * ``` + * Or subscribe to all event types: + * + * ```javascript + * space.members.subscribe((memberUpdate) => { + * console.log(memberUpdate); + * }); + * ``` + * The following is an example payload of a member event. The `lastEvent.name` describes which [event type](#events) a payload relates to. + * + * ```json + * { + * "clientId": "clemons#142", + * "connectionId": "hd9743gjDc", + * "isConnected": true, + * "lastEvent": { + * "name": "enter", + * "timestamp": 1677595689759 + * }, + * "location": null, + * "profileData": { + * "username": "Claire Lemons", + * "avatar": "https://slides-internal.com/users/clemons.png" + * } + * } + * ``` + * The following are the properties of a member event payload: + * + * | Property | Description | Type | + * |---------------------|-------------------------------------------------------------------------------------------------------------------|---------| + * | clientId | The [client identifier](https://ably.com/docs/auth/identified-clients) for the member. | String | + * | connectionId | The unique identifier of the member’s [connection](https://ably.com/docs/connect). | String | + * | isConnected | Whether the member is connected to Ably or not. | Boolean | + * | profileData | The optional [profile data](#profile-data) associated with the member. | Object | + * | location | The current [location](/spaces/locations) of the member. Will be `null` for `enter`, `leave` and `remove` events. | Object | + * | lastEvent.name | The most recent event emitted by the member. | String | + * | lastEvent.timestamp | The timestamp of the most recently emitted event. | Number | + * + * > **Further reading** + * > + * > Avatar stack subscription listeners only trigger on events related to members’ online status and profile updates. Each event only contains the payload of the member that triggered it. Alternatively, [space state](/spaces/space) can be subscribed to which returns an array of all members with their latest state every time any event is triggered. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Listen to member events for the space. See [EventEmitter](/docs/usage.md#event-emitters) for overloaded usage. + * + * The argument supplied to the callback is the [SpaceMember](#spacemember) object representing the member that triggered the event. + * + * Example: + * + * ```ts + * space.members.subscribe((member: SpaceMember) => {}); + * ``` + * + * Available events: + * + * - ##### **enter** + * + * Listen to enter events of members. + * + * ```ts + * space.members.subscribe('enter', (member: SpaceMember) => {}) + * ``` + * The argument supplied to the callback is a [SpaceMember](#spacemember) object representing the member entering the space. + * + * - ##### **leave** + * + * Listen to leave events of members. The leave event will be issued when a member calls `space.leave()` or is disconnected. + * + * ```ts + * space.members.subscribe('leave', (member: SpaceMember) => {}) + * ``` + * + * The argument supplied to the callback is a [SpaceMember](#spacemember) object representing the member leaving the space. + * + * - ##### **remove** + * + * Listen to remove events of members. The remove event will be triggered when the [offlineTimeout](#spaceoptions) has passed. + * + * ```ts + * space.members.subscribe('remove', (member: SpaceMember) => {}) + * ``` + * + * The argument supplied to the callback is a [SpaceMember](#spacemember) object representing the member removed from the space. + * + * - ##### **updateProfile** + * + * Listen to profile update events of members. + * + * ```ts + * space.members.subscribe('updateProfile', (member: SpaceMember) => {}) + * ``` + * The argument supplied to the callback is a [SpaceMember](#spacemember) object representing the member entering the space. + * + * - ##### **update** + * + * Listen to `enter`, `leave`, `updateProfile` and `remove` events. + * + * ```ts + * space.members.subscribe('update', (member: SpaceMember) => {}) + * ``` + * + * The argument supplied to the callback is a [SpaceMember](#spacemember) object representing the member affected by the change. + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link MembersEventMap} type. + */ + subscribe( + eventOrEvents: K | K[], + listener?: EventListener, + ): void; + /** + * Behaves the same as { @link subscribe:WITH_EVENTS | the overload which accepts one or more event names }, but subscribes to _all_ events. + */ + subscribe(listener?: EventListener): void; + subscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.on(listenerOrEvents, listener); @@ -86,9 +484,63 @@ class Members extends EventEmitter { } } - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * > **Documentation source** + * > + * > The following documentation is copied from the Spaces documentation website. + * + * Unsubscribe from member events to remove previously registered listeners. + * + * The following is an example of removing a listener for one member event type: + * + * ```javascript + * space.members.unsubscribe('enter', listener); + * ``` + * It’s also possible to remove listeners for multiple member event types: + * + * ```javascript + * space.members.unsubscribe(['enter', 'leave'], listener); + * ``` + * Or remove all listeners: + * + * ```javascript + * space.members.unsubscribe(); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Remove all the event listeners or specific listeners. See [EventEmitter](/docs/usage.md#event-emitters) for detailed usage. + * + * ```ts + * // Unsubscribe from all events + * space.members.unsubscribe(); + * + * // Unsubscribe from enter events + * space.members.unsubscribe('enter'); + * + * // Unsubscribe from leave events + * space.members.unsubscribe('leave'); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link MembersEventMap} type. + */ + unsubscribe( + eventOrEvents: K | K[], + listener?: EventListener, + ): void; + /** + * Behaves the same as { @link unsubscribe:WITH_EVENTS | the overload which accepts one or more event names }, but unsubscribes from _all_ events. + */ + unsubscribe(listener?: EventListener): void; + unsubscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.off(listenerOrEvents, listener); @@ -103,12 +555,13 @@ class Members extends EventEmitter { } } + /** @internal */ async getByConnectionId(connectionId: string): Promise { const members = await this.getAll(); return members.find((m) => m.connectionId === connectionId) ?? null; } - createMember(message: PresenceMember): SpaceMember { + private createMember(message: PresenceMember): SpaceMember { return { clientId: message.clientId, connectionId: message.connectionId, @@ -122,7 +575,7 @@ class Members extends EventEmitter { }; } - async onMemberOffline(member: SpaceMember) { + private async onMemberOffline(member: SpaceMember) { this.leavers.removeLeaver(member.connectionId); this.emit('remove', member); diff --git a/src/Space.ts b/src/Space.ts index 50dec7d2..4c8d00c0 100644 --- a/src/Space.ts +++ b/src/Space.ts @@ -1,11 +1,6 @@ import Ably, { Types } from 'ably'; -import EventEmitter, { - InvalidArgumentError, - inspect, - type EventKey, - type EventListener, -} from './utilities/EventEmitter.js'; +import EventEmitter, { InvalidArgumentError, inspect, type EventListener } from './utilities/EventEmitter.js'; import Locations from './Locations.js'; import Cursors from './Cursors.js'; import Members from './Members.js'; @@ -29,21 +24,96 @@ const SPACE_OPTIONS_DEFAULTS = { }, }; -type SpaceEventsMap = { - update: { members: SpaceMember[] }; -}; +export namespace SpaceEvents { + export interface UpdateEvent { + members: SpaceMember[]; + } +} + +export interface SpaceEventMap { + /** + * The property names of `SpaceEventMap` are the names of the events emitted by { @link Space }. + */ + update: SpaceEvents.UpdateEvent; +} -class Space extends EventEmitter { - readonly channelName: string; +/** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * A space is a virtual area of your application in which realtime collaboration between users can take place. You can have any number of virtual spaces within an application, with a single space being anything from a web page, a sheet within a spreadsheet, an individual slide in a slideshow, or the entire slideshow itself. + * + * The following features can be implemented within a space: + * + * - [Avatar stack](/spaces/avatar) + * - [Member location](/spaces/locations) + * - [Live cursors](/spaces/cursors) + * - [Component locking](/spaces/locking) + * + * A `Space` instance consists of a state object that represents the realtime status of all members in a given virtual space. This includes a list of which members are currently online or have recently left and each member’s location within the application. The position of members’ cursors are excluded from the space state due to their high frequency of updates. In the beta release, which UI components members have locked are also excluded from the space state. + * + * Space state can be {@link subscribe | subscribed} to by using a `Space` object. Alternatively, subscription listeners can be registered for individual features, such as avatar stack events and member location updates. These individual subscription listeners are intended to provide flexibility when implementing collaborative features. Individual listeners are client-side filtered events, so irrespective of whether you choose to subscribe to the space state or individual listeners, each event only counts as a single message. + * + * To subscribe to any events in a space, you first need to create or retrieve a space. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * An instance of a Space created using {@link default.get | spaces.get}. Inherits from {@link EventEmitter}. + * + */ +class Space extends EventEmitter { + private readonly channelName: string; readonly connectionId: string | undefined; readonly options: SpaceOptions; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * An instance of {@link Locations}. + * + * ```ts + * type locations = instanceof Locations; + * ``` + * + */ readonly locations: Locations; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * An instance of {@link Cursors}. + * + * ```ts + * type cursors = instanceof Cursors; + * ``` + * + */ readonly cursors: Cursors; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * An instance of {@link Members}. + * + * ```ts + * type members = instanceof Members; + * ``` + * + */ readonly members: Members; readonly channel: Types.RealtimeChannelPromise; readonly locks: Locks; readonly name: string; + /** @internal */ constructor(name: string, readonly client: Types.RealtimePromise, options?: Subset) { super(); @@ -105,6 +175,60 @@ class Space extends EventEmitter { this.emit('update', { members: await this.members.getAll() }); } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * Entering a space will register a client as a member and emit an {@link MembersEventMap.enter | `enter` } event to all subscribers. Use the `enter()` method to enter a space. + * + * Being entered into a space is required for members to: + * + * - Update their [profile data](#update-profile). + * - Set their [location](/spaces/locations). + * - Set their [cursor position](/spaces/cursors). + * + * The following is an example of entering a space: + * + * ```javascript + * await space.enter(); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * > **Moved documentation** + * > + * > This documentation has been moved to { @link ProfileData }. + * + * Profile data is returned in the payload of all space events. + * + * The following is an example of setting profile data when entering a space: + * + * ```javascript + * await space.enter({ + * username: 'Claire Oranges', + * avatar: 'https://slides-internal.com/users/coranges.png', + * }); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * + * Enter the space. Can optionally take `profileData`. This data can be an arbitrary JSON-serializable object which will be attached to the [member object](#spacemember). Returns all current space members. + * + * ```ts + * type enter = (profileData?: Record) => Promise; + * ``` + * + * + * @param profileData Data to associate with the member who is entering the space. + */ async enter(profileData: ProfileData = null): Promise { return new Promise((resolve) => { const presence = this.channel.presence; @@ -127,6 +251,49 @@ class Space extends EventEmitter { }); } + /** + * + * + * Profile data can be updated at any point after entering a space by calling `updateProfileData()`. This will emit an `update` event. If a client hasn’t yet entered the space, `updateProfileData()` will instead {@link enter | enter the space}, with the profile data, and emit an { @link MembersEventMap.enter | `enter` } event. + * + * The following is an example of updating profile data: + * + * ```javascript + * space.updateProfileData({ + * username: 'Claire Lemons', + * avatar: 'https://slides-internal.com/users/clemons.png', + * }); + * ``` + * A function can be passed to `updateProfileData()` in order to update a field based on the existing profile data: + * + * ```javascript + * space.updateProfileData(currentProfile => { + * return { ...currentProfile, username: 'Clara Lemons' } + * }); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Update `profileData`. This data can be an arbitrary JSON-serializable object which is attached to the [member object](#spacemember). If the connection + * has not entered the space, calling `updateProfileData` will call `enter` instead. + * + * ```ts + * type updateProfileData = (profileDataOrUpdateFn?: unknown| (unknown) => unknown) => Promise; + * ``` + * + * A function can also be passed in. This function will receive the existing `profileData` and lets you update based on the existing value of `profileData`: + * + * ```ts + * await space.updateProfileData((oldProfileData) => { + * const newProfileData = getNewProfileData(); + * return { ...oldProfileData, ...newProfileData }; + * }) + * ``` + * + */ async updateProfileData(profileDataOrUpdateFn: ProfileData | ((update: ProfileData) => ProfileData)): Promise { const self = await this.members.getSelf(); @@ -152,6 +319,30 @@ class Space extends EventEmitter { } } + /* + * + * Leaving a space will emit a { @link MembersEventMap.leave | `leave` } event to all subscribers. + * + * The following is an example of explicitly leaving a space: + * + * ```javascript + * await space.leave(); + * ``` + * Members will implicitly leave a space after 15 seconds if they abruptly disconnect. If experiencing network disruption, and they reconnect within 15 seconds, then they will remain part of the space and no `leave` event will be emitted. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Leave the space. Can optionally take `profileData`. This triggers the `leave` event, but does not immediately remove the member from the space. See [offlineTimeout](#spaceoptions). + * + * ```ts + * type leave = (profileData?: Record) => Promise; + * ``` + * + */ async leave(profileData: ProfileData = null) { const self = await this.members.getSelf(); @@ -172,14 +363,110 @@ class Space extends EventEmitter { await this.presenceLeave(data); } + /** + * + * The current state of the space can be retrieved in a one-off call. This will return an array of all `member` objects currently in the space. This is a local call and retrieves the membership of the space retained in memory by the SDK. + * + * The following is an example of retrieving the current space state. Ths includes members that have recently left the space, but have not yet been removed: + * + * ```javascript + * const spaceState = await space.getState(); + * ``` + * + */ async getState(): Promise<{ members: SpaceMember[] }> { const members = await this.members.getAll(); return { members }; } - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * + * Subscribe to space state updates by registering a listener. Use the `subscribe()` method on the `space` object to receive updates. + * + * The following events will trigger a space event: + * + * - A member enters the space + * - A member leaves the space + * - A member is removed from the space state [after the offlineTimeout period](#options) has elapsed + * - A member updates their profile data + * - A member sets a new location + * + * Space state contains a single object called `members`. Any events that trigger a change in space state will always return the current state of the space as an array of `member` objects. + * + * > **Note** + * > + * > [Avatar stacks](/spaces/members) and [member location](/spaces/locations) events can be subscribed to using the space’s {@link members | `members` } and {@link locations | `locations` } properties. These events are filtered versions of space state events. Only a single [message](https://ably.com/docs/channels/messages) is published per event by Ably, irrespective of whether you register listeners for space state or individual namespaces. If you register listeners for both, it is still only a single message. + * > + * > The key difference between the subscribing to space state or to individual feature events, is that space state events return the current state of the space as an array of all members in each event payload. Individual member and location event payloads only include the relevant data for the member that triggered the event. + * + * The following is an example of subscribing to space events: + * + * ```javascript + * space.subscribe('update', (spaceState) => { + * console.log(spaceState.members); + * }); + * ``` + * The following is an example payload of a space event. + * + * ```json + * [ + * { + * "clientId": "clemons#142", + * "connectionId": "hd9743gjDc", + * "isConnected": false, + * "lastEvent": { + * "name": "leave", + * "timestamp": 1677595689759 + * }, + * "location": null, + * "profileData": { + * "username": "Claire Lemons", + * "avatar": "https://slides-internal.com/users/clemons.png" + * } + * }, + * { + * "clientId": "amint#5", + * "connectionId": "hg35a4fgjAs", + * "isConnected": true, + * "lastEvent": { + * "name": "enter", + * "timestamp": 173459567340 + * }, + * "location": null, + * "profileData": { + * "username": "Arit Mint", + * "avatar": "https://slides-internal.com/users/amint.png" + * } + * }, + * ... + * ] + * ``` + * The following are the properties of an individual `member` within a space event payload: + * + * | Property | Description | Type | + * |---------------------|------------------------------------------------------------------------|---------| + * | clientId | The [client identifier](https://ably.com/docs/auth/identified-clients) for the member. | String | + * | connectionId | The unique identifier of the member’s [connection](https://ably.com/docs/connect). | String | + * | isConnected | Whether the member is connected to Ably or not. | Boolean | + * | profileData | The optional [profile data](#profile-data) associated with the member. | Object | + * | location | The current [location](/spaces/locations) of the member. | Object | + * | lastEvent.name | The most recent event emitted by the member. | String | + * | lastEvent.timestamp | The timestamp of the most recently emitted event. | Number | + * + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link SpaceEventMap} type. + */ + subscribe(eventOrEvents: K | K[], listener?: EventListener): void; + /** + * Behaves the same as { @link subscribe:WITH_EVENTS | the overload which accepts one or more event names }, but subscribes to _all_ events. + */ + subscribe(listener?: EventListener): void; + subscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.on(listenerOrEvents, listener); @@ -194,9 +481,29 @@ class Space extends EventEmitter { } } - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, + /** + * {@label WITH_EVENTS} + * + * + * Unsubscribe from space events to remove previously registered listeners. + * + * The following is an example of removing a listener: + * + * ```javascript + * space.unsubscribe('update', listener); + * ``` + * + * + * @typeParam K A type which allows one or more names of the properties of the {@link SpaceEventMap} type. + */ + unsubscribe(eventOrEvents: K | K[], listener?: EventListener): void; + /** + * Behaves the same as { @link unsubscribe:WITH_EVENTS | the overload which accepts one or more event names }, but unsubscribes from _all_ events. + */ + unsubscribe(listener?: EventListener): void; + unsubscribe( + listenerOrEvents?: K | K[] | EventListener, + listener?: EventListener, ) { try { super.off(listenerOrEvents, listener); diff --git a/src/Spaces.ts b/src/Spaces.ts index 5f252819..83ff6356 100644 --- a/src/Spaces.ts +++ b/src/Spaces.ts @@ -14,11 +14,69 @@ export interface ClientWithOptions extends Types.RealtimePromise { class Spaces { private spaces: Record = {}; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Instance of the [Ably-JS](https://github.com/ably/ably-js#introduction) client that was passed to the {@link constructor}. + * + * ```ts + * type client = Ably.RealtimePromise; + * ``` + * + */ client: Types.RealtimePromise; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Instance of the [Ably-JS](https://github.com/ably/ably-js#introduction) connection, belonging to the client that was passed to the {@link constructor}. + * + * ```ts + * type connection = Ably.ConnectionPromise; + * ``` + * + */ connection: Types.ConnectionPromise; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Version of the Spaces library. + * + * ```ts + * type version = string; + * ``` + * + */ readonly version = '0.1.3'; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Create a new instance of the Space SDK by passing an instance of the realtime, promise-based [Ably client](https://github.com/ably/ably-js): + * + * ```ts + * import { Realtime } from 'ably/promise'; + * import Spaces from '@ably/spaces'; + * + * const client = new Realtime.Promise({ key: "", clientId: "" }); + * const spaces = new Spaces(client); + * ``` + * + * Please note that a [clientId](https://ably.com/docs/auth/identified-clients?lang=javascript) is required. + * + * An API key will required for [basic authentication](https://ably.com/docs/auth/basic?lang=javascript). We strongly recommended that you use [token authentication](https://ably.com/docs/realtime/authentication#token-authentication) in any production environments. + * + * Refer to the [Ably docs for the JS SDK](https://ably.com/docs/getting-started/setup?lang=javascript) for information on setting up a realtime promise client. + * + */ constructor(client: Types.RealtimePromise) { this.client = client; this.connection = client.connection; @@ -31,6 +89,112 @@ class Spaces { options.agents = { ...(options.agents ?? options.agents), ...agent }; } + /** + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * + * A `space` object is a reference to a single space and is uniquely identified by its unicode string name. A space is created, or an existing space is retrieved from the `spaces` collection using the {@link get | `get()`} method. + * + * The following restrictions apply to space names: + * + * - Avoid starting names with `[` or `:` + * - Ensure names aren’t empty + * - Exclude whitespace and wildcards, such as `*` + * - Use the correct case, whether it be uppercase or lowercase + * + * The following is an example of creating a space: + * + * ```javascript + * const space = await spaces.get('board-presentation'); + * ``` + * + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * ## Advanced properties + * + * The following sections are only relevant if you want to further customize a space, or understand more about the Spaces SDK. They aren’t required to get up and running with the basics. + * + * ### Space options + * + * An additional set of optional properties may be passed when [creating or retrieving](#create) a space to customize the behavior of different features. + * + * The following properties can be customized: + * + * | Property | Description | Type | + * |-------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------| + * | offlineTimeout | Number of milliseconds after a member loses connection or closes their browser window to wait before they are removed from the member list. The default is 120,000ms (2 minutes). | Number | + * | cursors | A {@link CursorsOptions | cursor options} object for customizing live cursor behavior. | Object | + * | cursors.outboundBatchInterval | The interval, in milliseconds, at which a batch of cursor positions are published. This is multiplied by the number of members in a space, minus 1. The default value is 100ms. | Number | + * | cursors.paginationLimit | The number of pages searched from history for the last published cursor position. The default is 5. | Number | + * + * The following is an example of customizing the space options when calling `spaces.get()`: + * + * ```javascript + * const space = await spaces.get('board-presentation', { + * offlineTimeout: 180_000, + * cursors: { paginationLimit: 10 } + * }); + * ``` + * ### Space foundations + * + * The Spaces SDK is built upon existing Ably functionality available in Ably’s Core SDKs. Understanding which core features are used to provide the abstractions in the Spaces SDK enables you to manage space state and build additional functionality into your application. + * + * A space is created as an Ably [channel](/channels). Members [attach](https://ably.com/docs/channels#attach) to the channel and join its [presence set](https://ably.com/docs/presence-occupancy/presence) when they [enter](#enter) the space. Avatar stacks, member locations and component locking are all handled on this channel. + * + * To manage the state of the space, you can monitor the [state of the underlying channel](https://ably.com/docs/channels#states). The channel object can be accessed through {@link Space.channel | `space.channel`}. + * + * The following is an example of registering a listener to wait for a channel to become attached: + * + * ```javascript + * space.channel.on('attached', (stateChange) => { + * console.log(stateChange) + * }); + * ``` + * + * > **Note** + * > + * > Due to the high frequency at which updates are streamed for cursor movements, live cursors utilizes its own [channel](https://ably.com/docs/channels). + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from the [Spaces documentation website](https://ably.com/docs/spaces). + * + * ## Cursor options + * + * Cursor options are set when creating or retrieving a {@link Space | `Space` } instance. They are used to control the behavior of live cursors. + * + * The following cursor options can be set: + * + * ### outboundBatchInterval + * + * The `outboundBatchInterval` is the interval at which a batch of cursor positions are published, in milliseconds, for each client. This is multiplied by the number of members in a space. + * + * The default value is 25ms which is optimal for the majority of use cases. If you wish to optimize the interval further, then decreasing the value will improve performance by further ‘smoothing’ the movement of cursors at the cost of increasing the number of events sent. Be aware that at a certain point the rate at which a browser is able to render the changes will impact optimizations. + * + * ### paginationLimit + * + * The volume of messages sent can be high when using live cursors. Because of this, the last known position of every members’ cursor is obtained from [history](https://ably.com/docs/storage-history/history). The `paginationLimit` is the number of pages that should be searched to find the last position of each cursor. The default is 5. + * + * + * + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Get or create a Space instance. Returns a {@link Space} instance. Configure the space by passing {@link SpaceOptions} as the second argument. + * + * ```ts + * type get = (name: string, options?: SpaceOptions) => Promise; + * ``` + * + */ async get(name: string, options?: Subset): Promise { if (typeof name !== 'string' || name.length === 0) { throw ERR_SPACE_NAME_MISSING(); diff --git a/src/index.ts b/src/index.ts index f51aacdb..ffb2b1b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,11 @@ import Spaces from './Spaces.js'; -export type Space = Awaited>; +export type { default as Space, SpaceEventMap, SpaceEvents } from './Space.js'; + +export type { default as Cursors, CursorsEventMap } from './Cursors.js'; +export type { default as Locations, LocationsEventMap, LocationsEvents } from './Locations.js'; +export type { default as Locks, LocksEventMap, LockOptions } from './Locks.js'; +export type { default as Members, MembersEventMap } from './Members.js'; // Can be changed to * when we update to TS5 @@ -16,6 +21,11 @@ export type { SpaceMember, Lock, LockStatus, + LockStatuses, } from './types.js'; export { LockAttributes } from './Locks.js'; + +export type { default as EventEmitter, EventListener } from './utilities/EventEmitter.js'; + +export type { Subset } from './utilities/types.js'; diff --git a/src/types.ts b/src/types.ts index 9641b53c..068109f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,51 +1,326 @@ import { Types } from 'ably'; import type { LockAttributes } from './Locks.js'; +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * ```ts + * type CursorsOptions = { + * outboundBatchInterval?: number; + * paginationLimit?: number; + * }; + * ``` + * + */ export interface CursorsOptions { + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * The interval in milliseconds at which a batch of cursor positions are published. This is multiplied by the number of members in the space minus 1. The default value is 25ms. + * + */ outboundBatchInterval: number; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * The number of pages searched from [history](https://ably.com/docs/storage-history/history) for the last published cursor position. The default is 5. + * + */ paginationLimit: number; } +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Represents a cursors position. + * + * ```ts + * type CursorPosition = { + * x: number; + * y: number; + * }; + * ``` + * + */ export interface CursorPosition { + /** + * + * The position of the member’s cursor on the X-axis. + */ x: number; + /** + * + * The position of the member’s cursor on the Y-axis. + */ y: number; } +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Represent data that can be associated with a cursor update. + * + * ```ts + * type CursorData = Record; + * ``` + * + */ export type CursorData = Record; +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Represents an update to a cursor. + * + * ```ts + * type CursorUpdate = { + * name: string; + * clientId: string; + * connectionId: string; + * position: CursorPosition; + * data?: CursorData; + * }; + * ``` + * + */ export interface CursorUpdate { + /** + * + * The [client identifier](https://ably.com/docs/auth/identified-clients) for the member. + */ clientId: string; + /** + * + * The unique identifier of the member’s [connection](https://ably.com/docs/connect). + */ connectionId: string; + /** + * + * An object containing the position of a member’s cursor. + */ position: CursorPosition; + /** + * + * An optional arbitrary JSON-serializable object containing additional information about the cursor. + */ data?: CursorData; } +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Used to configure a Space instance on creation. + * + * ```ts + * type SpaceOptions = { + * offlineTimeout?: number; + * cursors?: CursorsOptions; + * }; + * ``` + * + */ export interface SpaceOptions { + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Number of milliseconds after a user loses connection or closes their browser window to wait before their [SpaceMember](#spacemember) object is removed from the members list. The default is 120000ms (2 minutes). + * + */ offlineTimeout: number; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Options relating to configuring the cursors API (see below). + * + */ cursors: CursorsOptions; } +/** + * + * Profile data can be set when {@link Space.enter | entering } a space. It is optional data that can be used to associate information with a member, such as a preferred username, or profile picture that can be subsequently displayed in their avatar. Profile data can be any arbitrary JSON-serializable object. + */ export type ProfileData = Record | null; +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * A SpaceMember represents a member within a Space instance. Each new connection that enters will create a new member, even if they have the same [`clientId`](https://ably.com/docs/auth/identified-clients?lang=javascript). + * + * ```ts + * type SpaceMember = { + * clientId: string; + * connectionId: string; + * isConnected: boolean; + * profileData: Record; + * location: Location; + * lastEvent: PresenceEvent; + * }; + * ``` + * + */ export interface SpaceMember { + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * The client identifier for the user, provided to the ably client instance. + * + */ clientId: string; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Identifier for the connection used by the user. This is a unique identifier. + * + */ connectionId: string; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Whether the user is connected to Ably. + * + */ isConnected: boolean; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Optional user data that can be attached to a user, such as a username or image to display in an avatar stack. + * + */ profileData: ProfileData; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * The current location of the user within the space. + * + */ location: unknown; + /** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * The most recent event emitted by [presence](https://ably.com/docs/presence-occupancy/presence?lang=javascript) and its timestamp. Events will be either `enter`, `leave`, `update` or `present`. + * + */ lastEvent: { name: Types.PresenceAction; timestamp: number; }; } -export type LockStatus = 'pending' | 'locked' | 'unlocked'; +/** + * The `LockStatuses` namespace describes the possible values of the {@link LockStatus} type. + */ +export namespace LockStatuses { + /** + * + * A member has requested a lock by calling { @link Locks.acquire | `acquire()` }. + */ + export type Pending = 'pending'; + /** + * + * The lock is confirmed to be held by the requesting member. + */ + export type Locked = 'locked'; + /** + * + * The lock is confirmed to not be locked by the requesting member, or has been { @link Locks.release | released } by a member previously holding the lock. + */ + export type Unlocked = 'unlocked'; +} + +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Represents a status of a lock. + * + * ```ts + * type LockStatus = 'pending' | 'locked' | 'unlocked'; + * ``` + * + */ +export type LockStatus = LockStatuses.Pending | LockStatuses.Locked | LockStatuses.Unlocked; +/** + * > **Documentation source** + * > + * > The following documentation is copied from `docs/class-definitions.md`. + * + * Represents a Lock. + * + * ```ts + * type Lock = { + * id: string; + * status: LockStatus; + * member: SpaceMember; + * timestamp: number; + * attributes?: LockAttributes; + * reason?: Types.ErrorInfo; + * }; + * ``` + * + */ export type Lock = { + /** + * + * The unique ID of the lock request. + */ id: string; + /** + * + * The lock status of the event. + */ status: LockStatus; + /** + * Information about the space member who requested the lock. + */ member: SpaceMember; + /** + * + * The timestamp of the lock event. + */ timestamp: number; + /** + * + * The optional attributes of the lock, such as the ID of the component it relates to. + */ attributes?: LockAttributes; + /** + * + * The reason why the `request.status` is `unlocked`. + */ reason?: Types.ErrorInfo; }; diff --git a/src/utilities/EventEmitter.test.ts b/src/utilities/EventEmitter.test.ts index aa4c87f6..8e2a62e6 100644 --- a/src/utilities/EventEmitter.test.ts +++ b/src/utilities/EventEmitter.test.ts @@ -138,7 +138,6 @@ describe('EventEmitter', () => { eventEmitter['once'](altListener); eventEmitter['once']('myEvent', context.spy); eventEmitter['once']('myEvent', altListener); - eventEmitter['once'](['myEvent', 'myOtherEvent', 'myThirdEvent'], altListener); context.eventEmitter = eventEmitter; }); @@ -171,11 +170,11 @@ describe('EventEmitter', () => { context.eventEmitter['emit']('myEvent', ''); expect(context.spy).toHaveBeenCalledTimes(2); context.eventEmitter['emit']('myOtherEvent', ''); - expect(context.spy).toHaveBeenCalledTimes(3); + expect(context.spy).toHaveBeenCalledTimes(4); }); it('removes a specific listener from multiple events', () => { - const eventEmitter = new EventEmitter(); + const eventEmitter = new EventEmitter<{ myEvent: unknown; myOtherEvent: unknown; myThirdEvent: unknown }>(); const specificListener = vi.fn(); eventEmitter['on'](['myEvent', 'myOtherEvent', 'myThirdEvent'], specificListener); eventEmitter['off'](['myEvent', 'myOtherEvent'], specificListener); @@ -254,18 +253,6 @@ describe('EventEmitter', () => { context.eventEmitter['emit']('myEvent', ''); expect(context.spy).toHaveBeenCalledOnce(); }); - - it('adds a listener to multiple eventOnce fields on calling `once` with a listener and event name; and after emitting any of the events, all are removed', (context) => { - context.eventEmitter['once'](['myEvent', 'myOtherEvent', 'myThirdEvent'], context.spy); - expect(context.eventEmitter['eventsOnce']['myEvent']).toHaveLength(1); - expect(context.eventEmitter['eventsOnce']['myOtherEvent']).toHaveLength(1); - expect(context.eventEmitter['eventsOnce']['myThirdEvent']).toHaveLength(1); - expect(context.eventEmitter['emit']('myEvent', '')); - expect(context.eventEmitter['eventsOnce']['myEvent']).toBe(undefined); - expect(context.eventEmitter['eventsOnce']['myOtherEvent']).toBe(undefined); - expect(context.eventEmitter['eventsOnce']['myThirdEvent']).toBe(undefined); - expect(context.spy).toHaveBeenCalledOnce(); - }); }); describe('calling the emit method', () => { diff --git a/src/utilities/EventEmitter.ts b/src/utilities/EventEmitter.ts index 247645a2..3d08ca27 100644 --- a/src/utilities/EventEmitter.ts +++ b/src/utilities/EventEmitter.ts @@ -1,8 +1,8 @@ import { isArray, isFunction, isObject, isString } from './is.js'; -function callListener(eventThis: { event: string }, listener: Function, args: unknown[]) { +function callListener(eventThis: { event: K }, listener: EventListener, arg: T[K]) { try { - listener.apply(eventThis, args); + listener.apply(eventThis, [arg]); } catch (e) { console.error( 'EventEmitter.emit()', @@ -17,14 +17,14 @@ function callListener(eventThis: { event: string }, listener: Function, args: un * @param listener the listener callback to remove * @param eventFilter (optional) event name instructing the function to only remove listeners for the specified event */ -export function removeListener( - targetListeners: (Function[] | Record)[], +export function removeListener( + targetListeners: (Function[] | Record)[], listener: Function, - eventFilter?: string, + eventFilter?: keyof T, ) { - let listeners: Function[] | Record; + let listeners: Function[] | Record; let index: number; - let eventName: string; + let eventName: keyof T; for (let targetListenersIndex = 0; targetListenersIndex < targetListeners.length; targetListenersIndex++) { listeners = targetListeners[targetListenersIndex]; @@ -64,17 +64,29 @@ export class InvalidArgumentError extends Error { } } -export type EventMap = Record; -// extract all the keys of an event map and use them as a type -export type EventKey = string & keyof T; -export type EventListener = (params: T) => void; +/** + * @typeParam T The type of event data that this listener will receive. + * @typeParam K The name of the event that this listener will listen for. + */ +export type EventListener = (this: { event: K }, param: T[K]) => void; -export default class EventEmitter { +/** + * @typeParam T An object type, the names of whose properties are the names of the events that an instance of this class can emit. + */ +export default class EventEmitter { + /** @internal */ any: Array; - events: Record; + /** @internal */ + events: Record; + /** @internal */ anyOnce: Array; - eventsOnce: Record; + /** @internal */ + eventsOnce: Record; + /** + * @internal + * @typeParam T An object type, the names of whose properties are the names of the events that the constructed object can emit. + */ constructor() { this.any = []; this.events = Object.create(null); @@ -83,11 +95,25 @@ export default class EventEmitter { } /** + * {@label WITH_EVENTS} * Add an event listener - * @param listenerOrEvents (optional) the name of the event to listen to or the listener to be called. + * @param eventOrEvents the name of the event to listen to or the listener to be called. + * @param listener (optional) the listener to be called. + * + * @typeParam K A type which allows one or more names of the properties of {@link T}. + */ + on(eventOrEvents?: K | K[], listener?: EventListener): void; + /** + * Behaves the same as { @link on:WITH_EVENTS | the overload which accepts one or more event names }, but listens to _all_ events. * @param listener (optional) the listener to be called. */ - on>(listenerOrEvents?: K | K[] | EventListener, listener?: EventListener): void { + on(listener?: EventListener): void; + /** + * @internal + * We add the implementation signature as an overload signature (but mark it as internal so that it does not appear in documentation) so that it can be called by subclasses. + */ + on(listenerOrEvents?: K | K[] | EventListener, listener?: EventListener): void; + on(listenerOrEvents?: K | K[] | EventListener, listener?: EventListener): void { // .on(() => {}) if (isFunction(listenerOrEvents)) { this.any.push(listenerOrEvents); @@ -113,12 +139,25 @@ export default class EventEmitter { } /** + * {@label WITH_EVENTS} * Remove one or more event listeners - * @param listenerOrEvents (optional) the name of the event whose listener is to be removed. If not supplied, - * the listener is treated as an 'any' listener. + * @param eventOrEvents the name of the event whose listener is to be removed. * @param listener (optional) the listener to remove. If not supplied, all listeners are removed. + * + * @typeParam K A type which allows one or more names of the properties of {@link T}. TypeScript will infer this type based on the {@link eventOrEvents} argument. + */ + off(eventOrEvents?: K | K[], listener?: EventListener): void; + /** + * Behaves the same as { @link off:WITH_EVENTS | the overload which accepts one or more event names }, but removes the listener from _all_ events. + * @param listener (optional) the listener to remove. If not supplied, all listeners are removed. + */ + off(listener?: EventListener): void; + /** + * @internal + * We add the implementation signature as an overload signature (but mark it as internal so that it does not appear in documentation) so that it can be called by subclasses. */ - off>(listenerOrEvents?: K | K[] | EventListener, listener?: EventListener): void { + off(listenerOrEvents?: K | K[] | EventListener, listener?: EventListener): void; + off(listenerOrEvents?: K | K[] | EventListener, listener?: EventListener): void { // .off() // don't use arguments.length === 0 here as don't won't handle // cases like .off(undefined) which is a valid call @@ -137,7 +176,7 @@ export default class EventEmitter { } // .off("eventName", () => {}) - if (isString(listenerOrEvents) && isFunction(listener)) { + if (isString(listenerOrEvents) && listener) { removeListener([this.events, this.eventsOnce], listener, listenerOrEvents); return; } @@ -150,7 +189,7 @@ export default class EventEmitter { } // .off(["eventName"], () => {}) - if (isArray(listenerOrEvents) && isFunction(listener)) { + if (isArray(listenerOrEvents) && listener) { listenerOrEvents.forEach((eventName) => { this.off(eventName, listener); }); @@ -172,8 +211,10 @@ export default class EventEmitter { * Get the array of listeners for a given event; excludes once events * @param event (optional) the name of the event, or none for 'any' * @return array of events, or null if none + * + * @typeParam K A type which allows a name of the properties of {@link T}. TypeScript will infer this type based on the {@link event} argument. */ - listeners>(event: K): Function[] | null { + listeners(event: K): Function[] | null { if (event) { const listeners = [...(this.events[event] ?? [])]; @@ -188,13 +229,15 @@ export default class EventEmitter { } /** + * @internal + * * Emit an event * @param event the event name * @param arg the arguments to pass to the listener */ - emit>(event: K, arg: T[K]) { + emit(event: K, arg: T[K]) { const eventThis = { event }; - const listeners: Function[] = []; + const listeners: EventListener[] = []; if (this.anyOnce.length > 0) { Array.prototype.push.apply(listeners, this.anyOnce); @@ -217,64 +260,67 @@ export default class EventEmitter { } listeners.forEach(function (listener) { - callListener(eventThis, listener, [arg]); + callListener(eventThis, listener, arg); }); } /** + * {@label WITH_EVENTS} * Listen for a single occurrence of an event - * @param listenerOrEvents (optional) the name of the event to listen to + * @param event the name of the event to listen to * @param listener (optional) the listener to be called + * + * @typeParam K A type which allows a name of one of the properties of {@link T}. TypeScript will infer this type based on the {@link event} argument. + */ + once(event: K, listener?: EventListener): void | Promise; + /** + * Behaves the same as { @link once:WITH_EVENTS | the overload which accepts one or more event names }, but listens for _all_ events. + * @param listener (optional) the listener to be called + */ + once(listener?: EventListener): void | Promise; + /** + * @internal + * We add the implementation signature as an overload signature (but mark it as internal so that it does not appear in documentation) so that it can be called by subclasses. */ - once>( - listenerOrEvents: K | K[] | EventListener, - listener?: EventListener, + once( + listenerOrEvent: K | EventListener, + listener?: EventListener, + ): void | Promise; + once( + listenerOrEvent: K | EventListener, + listener?: EventListener, ): void | Promise { // .once("eventName", () => {}) - if (isString(listenerOrEvents) && isFunction(listener)) { - const listeners = this.eventsOnce[listenerOrEvents] || (this.eventsOnce[listenerOrEvents] = []); + if (isString(listenerOrEvent) && listener) { + const listeners = this.eventsOnce[listenerOrEvent] || (this.eventsOnce[listenerOrEvent] = []); listeners.push(listener); return; } - // .once(["eventName"], () => {}) - if (isArray(listenerOrEvents) && isFunction(listener)) { - const self = this; - - listenerOrEvents.forEach(function (eventName) { - const listenerWrapper: EventListener = function (this: EventListener, listenerThis) { - const innerArgs = Array.prototype.slice.call(arguments) as [params: T[K]]; - listenerOrEvents.forEach((eventName) => { - self.off(eventName, this); - }); - - listener.apply(listenerThis, innerArgs); - }; - self.once(eventName, listenerWrapper); - }); - - return; - } - // .once(() => {}) - if (isFunction(listenerOrEvents)) { - this.anyOnce.push(listenerOrEvents); + if (isFunction(listenerOrEvent)) { + this.anyOnce.push(listenerOrEvent); return; } - throw new InvalidArgumentError('EventEmitter.once(): invalid arguments:' + inspect([listenerOrEvents, listener])); + throw new InvalidArgumentError('EventEmitter.once(): invalid arguments:' + inspect([listenerOrEvent, listener])); } /** - * Private API + * @internal * * Listen for a single occurrence of a state event and fire immediately if currentState matches targetState * @param targetState the name of the state event to listen to * @param currentState the name of the current state of this object * @param listener the listener to be called - * @param listenerArgs + * @param listenerArg the argument to pass to the listener */ - whenState(targetState: string, currentState: string, listener: EventListener, ...listenerArgs: unknown[]) { + whenState( + targetState: K, + currentState: keyof T, + listener: EventListener, + listenerArg: T[K], + ) { const eventThis = { event: targetState }; if (typeof targetState !== 'string' || typeof currentState !== 'string') { @@ -282,14 +328,11 @@ export default class EventEmitter { } if (typeof listener !== 'function' && Promise) { return new Promise((resolve) => { - EventEmitter.prototype.whenState.apply( - this, - [targetState, currentState, resolve].concat(listenerArgs as any[]) as any, - ); + EventEmitter.prototype.whenState.apply(this, [targetState, currentState, resolve, listenerArg]); }); } if (targetState === currentState) { - callListener(eventThis, listener, listenerArgs); + callListener(eventThis, listener, listenerArg); } else { this.once(targetState, listener); } diff --git a/src/utilities/is.ts b/src/utilities/is.ts index 8a6fd0d3..5e3e4435 100644 --- a/src/utilities/is.ts +++ b/src/utilities/is.ts @@ -11,7 +11,7 @@ function isFunction(arg: unknown): arg is Function { return ['Function', 'AsyncFunction', 'GeneratorFunction', 'Proxy'].includes(typeOf(arg)); } -function isString(arg: unknown): arg is String { +function isString(arg: unknown): arg is string { return typeOf(arg) === 'String'; } diff --git a/src/utilities/types.ts b/src/utilities/types.ts index cd3e2f5a..ba3cfe99 100644 --- a/src/utilities/types.ts +++ b/src/utilities/types.ts @@ -1,6 +1,5 @@ import type { Types } from 'ably'; -import type { EventKey, EventListener, EventMap } from './EventEmitter.js'; import type { ProfileData, Lock } from '../types.js'; export type PresenceMember = { @@ -20,22 +19,13 @@ export type PresenceMember = { }; } & Omit; +/** + * @typeParam K The type from which `Subset` is derived. + */ export type Subset = { [attr in keyof K]?: K[attr] extends object ? Subset : K[attr]; }; -export interface Provider { - subscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, - ): void; - - unsubscribe>( - listenerOrEvents?: K | K[] | EventListener, - listener?: EventListener, - ): void; -} - export type RealtimeMessage = Omit & { connectionId: string; }; diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 00000000..9179c546 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["dist/mjs/index.d.ts"], + "excludeInternal": true, + "excludePrivate": true, + "includeVersion": true, + "out": "docs/typedoc/generated", + "readme": "docs/typedoc/intro.md", + "requiredToBeDocumented": [ + "Accessor", + "CallSignature", + "Class", + "Constructor", + "ConstructorSignature", + "Enum", + "EnumMember", + "Function", + "GetSignature", + "IndexSignature", + "Interface", + "Method", + "Module", + "Namespace", + "Parameter", + "Property", + "Reference", + "SetSignature", + "TypeAlias", + "TypeLiteral", + "TypeParameter", + "Variable", + ], + "validation": true +}