diff --git a/.changeset/public-houses-add.md b/.changeset/public-houses-add.md new file mode 100644 index 000000000000..71ee6eae7849 --- /dev/null +++ b/.changeset/public-houses-add.md @@ -0,0 +1,7 @@ +--- +"@fluidframework/presence": minor +"__section": feature +--- +"getPresence(container: IFluidContainer): Presence" now supported + +`getPresence` is now supported and may be used to directly acquire `Presence` instead of using `ExperimentalPresenceManager` in container schema and calling `getPresenceViaDataObject`. (Both of those are now deprecated.) diff --git a/.vscode/settings.json b/.vscode/settings.json index 89012c7451b3..056aa3903906 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -53,6 +53,7 @@ "mocharc", "multinomial", "nonfinite", + "privateremarks", "pseudorandomly", "reconnections", "Routerlicious", diff --git a/docs/docs/build/presence.md b/docs/docs/build/presence.md index 686fceeca40d..9a5d43692053 100644 --- a/docs/docs/build/presence.md +++ b/docs/docs/build/presence.md @@ -5,8 +5,7 @@ sidebar_postition: 10 ## Overview -We are introducting a new way to power your ephemeral experiences wth Fluid. Introducing the new Presence APIs (currently in alpha) that provide session-focused utilities for lightweight data sharing and messaging. - +We are introducing a new way to power your ephemeral experiences with Fluid. Introducing the new Presence APIs (currently in alpha) that provide session-focused utilities for lightweight data sharing and messaging. Collaborative features typically rely on each user maintaining their own temporary state, which is subsequently shared with others. For example, in applications featuring multiplayer cursors, the cursor position of each user signifies their state. This state can be further utilized for various purposes such as indicating typing activity or displaying a user's current selection. This concept is referred to as _presence_. By leveraging this shared state, applications can provide a seamless and interactive collaborative experience, ensuring that users are always aware of each other's actions and selections in real-time. @@ -57,21 +56,13 @@ Notifications are special case where no data is retained during a session and al ## Onboarding -While this package is developing as experimental and other Fluid Framework internals are being updated to accommodate it, a temporary Shared Object must be added within container to gain access. +To access Presence APIs, use `getPresence()` with any `IFluidContainer`. ```typescript -import { - getPresenceViaDataObject, - ExperimentalPresenceManager, -} from "@fluidframework/presence/alpha"; - -const containerSchema = { - initialObjects: { - presence: ExperimentalPresenceManager, - }, -} satisfies ContainerSchema; +import { getPresence } from "@fluidframework/presence/alpha"; -const presence = await getPresenceViaDataObject(container.initialObjects.presence); +function usePresence(container: IFluidContainer): void { + const presence = await getPresence(container); ``` ## Limitations diff --git a/examples/apps/ai-collab/src/app/page.tsx b/examples/apps/ai-collab/src/app/page.tsx index 7fe2e53e98fb..86222c590329 100644 --- a/examples/apps/ai-collab/src/app/page.tsx +++ b/examples/apps/ai-collab/src/app/page.tsx @@ -5,7 +5,7 @@ "use client"; -import { getPresenceViaDataObject } from "@fluidframework/presence/alpha"; +import { getPresence } from "@fluidframework/presence/alpha"; import { Box, Button, @@ -63,7 +63,7 @@ export default function TasksListPage(): JSX.Element { const _treeView = fluidContainer.initialObjects.appState.viewWith(TREE_CONFIGURATION); setTreeView(_treeView); - const presence = getPresenceViaDataObject(fluidContainer.initialObjects.presence); + const presence = getPresence(fluidContainer); setPresenceManagerContext(new PresenceManager(presence)); return { sharedTree: _treeView }; }, diff --git a/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts b/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts index 3b03f162dd6d..6e3465b7fce1 100644 --- a/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts +++ b/examples/apps/ai-collab/src/types/sharedTreeAppSchema.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. */ -import { ExperimentalPresenceManager } from "@fluidframework/presence/alpha"; import { Tree, type TreeNode, TreeViewConfiguration } from "@fluidframework/tree"; import { SchemaFactoryAlpha } from "@fluidframework/tree/alpha"; import { SharedTree } from "fluid-framework"; @@ -202,11 +201,6 @@ export const INITIAL_APP_STATE = { export const CONTAINER_SCHEMA = { initialObjects: { appState: SharedTree, - /** - * A Presence Manager object temporarily needs to be placed within container schema - * https://github.com/microsoft/FluidFramework/blob/main/packages/framework/presence/README.md#onboarding - * */ - presence: ExperimentalPresenceManager, }, }; diff --git a/examples/apps/presence-tracker/src/app.ts b/examples/apps/presence-tracker/src/app.ts index c392258362de..f0c976dd9a81 100644 --- a/examples/apps/presence-tracker/src/app.ts +++ b/examples/apps/presence-tracker/src/app.ts @@ -4,7 +4,10 @@ */ import { + getPresence, + // eslint-disable-next-line import/no-deprecated getPresenceViaDataObject, + // eslint-disable-next-line import/no-deprecated ExperimentalPresenceManager, } from "@fluidframework/presence/alpha"; import { TinyliciousClient } from "@fluidframework/tinylicious-client"; @@ -16,11 +19,15 @@ import { initializeReactions } from "./reactions.js"; import { renderControlPanel, renderFocusPresence, renderMousePresence } from "./view.js"; // Define the schema of the Fluid container. -// This example uses the presence features only, so only that data object is added. +// This example uses the presence features only, so no data object is required. +// But the old experimental presence data object is used to check that old path still works. +// Besides initialObjects is not currently allowed to be empty. +// That version of presence is compatible with all 2.x runtimes. Long-term support without +// data object requires 2.41 or later. const containerSchema = { initialObjects: { - // A Presence Manager object temporarily needs to be placed within container schema - // https://github.com/microsoft/FluidFramework/blob/main/packages/framework/presence/README.md#onboarding + // Optional Presence Manager object placed within container schema for experimental presence access + // eslint-disable-next-line import/no-deprecated presence: ExperimentalPresenceManager, }, } satisfies ContainerSchema; @@ -56,8 +63,12 @@ async function start() { ({ container } = await client.getContainer(id, containerSchema, "2")); } - // Retrieve a reference to the presence APIs via the data object. - const presence = getPresenceViaDataObject(container.initialObjects.presence); + const useDataObject = new URLSearchParams(location.search).has("useDataObject"); + const presence = useDataObject + ? // Retrieve a reference to the presence APIs via the data object. + // eslint-disable-next-line import/no-deprecated + getPresenceViaDataObject(container.initialObjects.presence) + : getPresence(container); // Get the states workspace for the tracker data. This workspace will be created if it doesn't exist. // We create it with no states; we will pass the workspace to the Mouse and Focus trackers, and they will create value diff --git a/examples/service-clients/azure-client/external-controller/src/app.ts b/examples/service-clients/azure-client/external-controller/src/app.ts index d1eff40133bb..e344a427f55e 100644 --- a/examples/service-clients/azure-client/external-controller/src/app.ts +++ b/examples/service-clients/azure-client/external-controller/src/app.ts @@ -11,10 +11,7 @@ import { } from "@fluidframework/azure-client"; import { createDevtoolsLogger, initializeDevtools } from "@fluidframework/devtools/beta"; import { ISharedMap, IValueChanged, SharedMap } from "@fluidframework/map/legacy"; -import { - getPresenceViaDataObject, - ExperimentalPresenceManager, -} from "@fluidframework/presence/alpha"; +import { getPresence } from "@fluidframework/presence/alpha"; import { createChildLogger } from "@fluidframework/telemetry-utils/legacy"; // eslint-disable-next-line import/no-internal-modules -- #26985: `test-runtime-utils` internal used in example import { InsecureTokenProvider } from "@fluidframework/test-runtime-utils/internal"; @@ -76,9 +73,6 @@ const containerSchema = { /* [id]: DataObject */ map1: SharedMap, map2: SharedMap, - // A Presence Manager object temporarily needs to be placed within container schema - // https://github.com/microsoft/FluidFramework/blob/main/packages/framework/presence/README.md#onboarding - presence: ExperimentalPresenceManager, }, } satisfies ContainerSchema; type DiceRollerContainerSchema = typeof containerSchema; @@ -182,7 +176,7 @@ async function start(): Promise { // Biome insist on no semicolon - https://dev.azure.com/fluidframework/internal/_workitems/edit/9083 const lastRoll: { die1?: DieValue; die2?: DieValue } = {}; - const presence = getPresenceViaDataObject(container.initialObjects.presence); + const presence = getPresence(container); const states = buildDicePresence(presence).states; // Initialize Devtools diff --git a/examples/service-clients/azure-client/external-controller/tests/index.ts b/examples/service-clients/azure-client/external-controller/tests/index.ts index 8c9646158365..15d300958c7e 100644 --- a/examples/service-clients/azure-client/external-controller/tests/index.ts +++ b/examples/service-clients/azure-client/external-controller/tests/index.ts @@ -9,8 +9,11 @@ import { IRuntimeFactory, } from "@fluidframework/container-definitions/legacy"; import { Loader } from "@fluidframework/container-loader/legacy"; -// eslint-disable-next-line import/no-internal-modules -- #26986: `fluid-static` internal used in examples -import { createDOProviderContainerRuntimeFactory } from "@fluidframework/fluid-static/internal"; +import { + createDOProviderContainerRuntimeFactory, + createFluidContainer, + // eslint-disable-next-line import/no-internal-modules -- #26986: `fluid-static` internal used in examples +} from "@fluidframework/fluid-static/internal"; // eslint-disable-next-line import/no-internal-modules -- #26987: `local-driver` internal used in examples import { LocalSessionStorageDbFactory } from "@fluidframework/local-driver/internal"; import { @@ -119,8 +122,7 @@ async function createContainerAndRenderInElement( ); // Get the Default Object from the Container - const fluidContainer = - (await container.getEntryPoint()) as IFluidContainer; + const fluidContainer = await createFluidContainer({ container }); if (createNewFlag) { await initializeNewContainer(fluidContainer); await attach?.(); diff --git a/packages/common/container-definitions/src/runtime.ts b/packages/common/container-definitions/src/runtime.ts index 4f036e5ea711..d0b9f9974f4a 100644 --- a/packages/common/container-definitions/src/runtime.ts +++ b/packages/common/container-definitions/src/runtime.ts @@ -56,6 +56,7 @@ export enum AttachState { /** * The IRuntime represents an instantiation of a code package within a Container. * Primarily held by the ContainerContext to be able to interact with the running instance of the Container. + * * @legacy * @alpha */ diff --git a/packages/common/core-interfaces/api-report/core-interfaces.legacy.alpha.api.md b/packages/common/core-interfaces/api-report/core-interfaces.legacy.alpha.api.md index 74230906fac6..0a8fac54f6a9 100644 --- a/packages/common/core-interfaces/api-report/core-interfaces.legacy.alpha.api.md +++ b/packages/common/core-interfaces/api-report/core-interfaces.legacy.alpha.api.md @@ -432,6 +432,12 @@ export type TelemetryBaseEventPropertyType = string | number | boolean | undefin // @public export type TransformedEvent = (event: E, listener: (...args: ReplaceIEventThisPlaceHolder) => void) => TThis; +// @alpha @legacy +export interface TypedMessage { + content: unknown; + type: string; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/common/core-interfaces/src/brandedType.ts b/packages/common/core-interfaces/src/brandedType.ts new file mode 100644 index 000000000000..231eba11b1ac --- /dev/null +++ b/packages/common/core-interfaces/src/brandedType.ts @@ -0,0 +1,43 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Base branded type which can be used to annotate other type. + * + * @remarks + * To use derive another class declaration and ideally add additional private + * properties to further distinguish the type. + * + * Since branded types are not real value types, they will always need to be + * created using `as` syntax and very often `as unknown` first. + * + * This class should never exist at runtime, so it is only declared. + * + * @sealed + * @internal + */ +export declare class BrandedType { + /** + * Compile time only marker to make type checking more strict. + * This method will not exist at runtime and accessing it is invalid. + * + * @privateRemarks + * `Brand` is used as the return type of a method rather than a simple + * readonly property as this allows types with two brands to be + * intersected without getting `never`. + * The method takes in `never` to help emphasize that it's not callable. + */ + protected readonly brand: (dummy: never) => Brand; + + protected constructor(); + + /** + * Since this class is a compile time only type brand, `instanceof` will + * never work with it. * This `Symbol.hasInstance` implementation ensures + * that `instanceof` will error if used, and in TypeScript 5.3 and newer + * will produce a compile time error if used. + */ + public static [Symbol.hasInstance](value: never): value is never; +} diff --git a/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts b/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts index 4fd2c790c097..491e80084834 100644 --- a/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts +++ b/packages/common/core-interfaces/src/exposedInternalUtilityTypes.ts @@ -535,20 +535,27 @@ export namespace InternalUtilityTypes { */ export type IsExactlyObject = IsSameType; + /** + * Any Record type. + * + * @system + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `any` for property types is required to avoid "Index signature for type 'string' is missing in type" in some outside `FlattenIntersection` uses. + export type AnyRecord = Record; + /** * Creates a simple object type from an intersection of multiple. * @privateRemarks - * `T extends Record` within the implementation encourages tsc to process + * `T extends AnyRecord` within the implementation encourages tsc to process * intersections within unions. * * @system */ - export type FlattenIntersection> = - T extends Record - ? { - [K in keyof T]: T[K]; - } - : T; + export type FlattenIntersection = T extends AnyRecord + ? { + [K in keyof T]: T[K]; + } + : T; /** * Extracts Function portion from an intersection (&) type returning diff --git a/packages/common/core-interfaces/src/index.ts b/packages/common/core-interfaces/src/index.ts index 586790d45274..06804cc54e22 100644 --- a/packages/common/core-interfaces/src/index.ts +++ b/packages/common/core-interfaces/src/index.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. */ +export type { BrandedType } from "./brandedType.js"; + export type { IDisposable } from "./disposable.js"; export type { IErrorBase, IGenericError, IUsageError, IThrottlingWarning } from "./error.js"; @@ -52,7 +54,7 @@ export type { export { LogLevel } from "./logger.js"; export type { FluidObjectProviderKeys, FluidObject, FluidObjectKeys } from "./provider.js"; export type { ConfigTypes, IConfigProviderBase } from "./config.js"; -export type { ISignalEnvelope } from "./messages.js"; +export type { ISignalEnvelope, TypedMessage } from "./messages.js"; export type { ErasedType } from "./erasedType.js"; export type { diff --git a/packages/common/core-interfaces/src/internal.ts b/packages/common/core-interfaces/src/internal.ts index 47a406dd8776..6294fd1649c9 100644 --- a/packages/common/core-interfaces/src/internal.ts +++ b/packages/common/core-interfaces/src/internal.ts @@ -76,12 +76,14 @@ export type ReadonlyNonNullJsonObjectWith = ExposedReadonlyNonNullJsonObjectW */ // eslint-disable-next-line @typescript-eslint/no-namespace export namespace InternalUtilityTypes { - // TODO: Add documentation - // eslint-disable-next-line jsdoc/require-jsdoc + /* eslint-disable jsdoc/require-jsdoc */ + export type FlattenIntersection = + ExposedInternalUtilityTypes.FlattenIntersection; export type IfSameType< X, Y, IfSame = unknown, IfDifferent = never, > = ExposedInternalUtilityTypes.IfSameType; + /* eslint-enable jsdoc/require-jsdoc */ } diff --git a/packages/common/core-interfaces/src/messages.ts b/packages/common/core-interfaces/src/messages.ts index 92d7d2ad9360..c53c571e3d17 100644 --- a/packages/common/core-interfaces/src/messages.ts +++ b/packages/common/core-interfaces/src/messages.ts @@ -3,6 +3,28 @@ * Licensed under the MIT License. */ +/** + * A message that has a string `type` associated with `content`. + * + * @remarks + * This type is meant to be used indirectly. Most commonly as a constraint + * for generics of message structures. + * + * @legacy + * @alpha + */ +export interface TypedMessage { + /** + * The type of the message. + */ + type: string; + + /** + * The contents of the message. + */ + content: unknown; +} + /** * @internal * @@ -13,7 +35,7 @@ * * See at `server/routerlicious/packages/lambdas/src/utils/messageGenerator.ts`. */ -export interface ISignalEnvelope { +export interface ISignalEnvelope { /** * The target for the envelope, undefined for the container */ @@ -27,9 +49,5 @@ export interface ISignalEnvelope { /** * The contents of the envelope */ - contents: { - type: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - content: any; - }; + contents: TMessage; } diff --git a/packages/common/driver-definitions/api-report/driver-definitions.legacy.alpha.api.md b/packages/common/driver-definitions/api-report/driver-definitions.legacy.alpha.api.md index 8d038339eeed..b6fb41972185 100644 --- a/packages/common/driver-definitions/api-report/driver-definitions.legacy.alpha.api.md +++ b/packages/common/driver-definitions/api-report/driver-definitions.legacy.alpha.api.md @@ -468,17 +468,17 @@ export interface ISignalClient { } // @alpha @legacy -export interface ISignalMessage extends ISignalMessageBase { +export interface ISignalMessage extends ISignalMessageBase { clientId: string | null; } // @alpha @legacy -export interface ISignalMessageBase { +export interface ISignalMessageBase { clientConnectionNumber?: number; - content: unknown; + content: TMessage["content"]; referenceSequenceNumber?: number; targetClientId?: string; - type?: string; + type?: TMessage["type"]; } // @alpha @legacy diff --git a/packages/common/driver-definitions/src/protocol/protocol.ts b/packages/common/driver-definitions/src/protocol/protocol.ts index c82a7b5c973f..347cff22ce64 100644 --- a/packages/common/driver-definitions/src/protocol/protocol.ts +++ b/packages/common/driver-definitions/src/protocol/protocol.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. */ +import type { TypedMessage } from "@fluidframework/core-interfaces/internal"; + /** * @legacy * @alpha @@ -341,16 +343,16 @@ export interface ISequencedDocumentAugmentedMessage extends ISequencedDocumentMe * @legacy * @alpha */ -export interface ISignalMessageBase { +export interface ISignalMessageBase { /** * Signal content */ - content: unknown; + content: TMessage["content"]; /** * Signal type */ - type?: string; + type?: TMessage["type"]; /** * Counts the number of signals sent by the sending client. @@ -374,7 +376,8 @@ export interface ISignalMessageBase { * @legacy * @alpha */ -export interface ISignalMessage extends ISignalMessageBase { +export interface ISignalMessage + extends ISignalMessageBase { /** * The client ID that submitted the message. * For server generated messages the clientId will be null. @@ -387,7 +390,8 @@ export interface ISignalMessage extends ISignalMessageBase { * Interface for signals sent by clients to the server. * @internal */ -export type ISentSignalMessage = ISignalMessageBase; +export type ISentSignalMessage = + ISignalMessageBase; /** * @legacy diff --git a/packages/framework/fluid-static/src/fluidContainer.ts b/packages/framework/fluid-static/src/fluidContainer.ts index 1bb95506a611..1c55828537dc 100644 --- a/packages/framework/fluid-static/src/fluidContainer.ts +++ b/packages/framework/fluid-static/src/fluidContainer.ts @@ -10,6 +10,7 @@ import { type ICriticalContainerError, } from "@fluidframework/container-definitions"; import type { IContainer } from "@fluidframework/container-definitions/internal"; +import type { ContainerExtensionStore } from "@fluidframework/container-runtime-definitions/internal"; import type { FluidObject, IEvent, @@ -19,7 +20,12 @@ import type { import { assert } from "@fluidframework/core-utils/internal"; import type { SharedObjectKind } from "@fluidframework/shared-object-base"; -import type { ContainerAttachProps, ContainerSchema, IRootDataObject } from "./types.js"; +import type { + ContainerAttachProps, + ContainerSchema, + IRootDataObject, + IStaticEntryPoint, +} from "./types.js"; /** * Extract the type of 'initialObjects' from the given {@link ContainerSchema} type. @@ -241,7 +247,7 @@ export interface IFluidContainer(props: { container: IContainer; }): Promise> { - const entryPoint: FluidObject = await props.container.getEntryPoint(); + const entryPoint: FluidObject = await props.container.getEntryPoint(); assert( - entryPoint.IRootDataObject !== undefined, - 0x875 /* entryPoint must be of type IRootDataObject */, + entryPoint.IStaticEntryPoint !== undefined, + "entryPoint must be of type IStaticEntryPoint", + ); + return new FluidContainer( + props.container, + entryPoint.IStaticEntryPoint.rootDataObject, + entryPoint.IStaticEntryPoint.extensionStore, ); - return new FluidContainer(props.container, entryPoint.IRootDataObject); } /** @@ -301,12 +311,15 @@ class FluidContainer this.emit("disposed", error); private readonly savedHandler = (): boolean => this.emit("saved"); private readonly dirtyHandler = (): boolean => this.emit("dirty"); + public readonly acquireExtension: ContainerExtensionStore["acquireExtension"]; public constructor( public readonly container: IContainer, private readonly rootDataObject: IRootDataObject, + extensionStore: ContainerExtensionStore, ) { super(); + this.acquireExtension = extensionStore.acquireExtension.bind(extensionStore); container.on("connected", this.connectedHandler); container.on("closed", this.disposedHandler); container.on("disconnected", this.disconnectedHandler); diff --git a/packages/framework/fluid-static/src/rootDataObject.ts b/packages/framework/fluid-static/src/rootDataObject.ts index bb3447b32f1c..6c590ca5555f 100644 --- a/packages/framework/fluid-static/src/rootDataObject.ts +++ b/packages/framework/fluid-static/src/rootDataObject.ts @@ -14,8 +14,16 @@ import { FluidDataStoreRegistry, type MinimumVersionForCollab, } from "@fluidframework/container-runtime/internal"; -import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; -import type { FluidObject, IFluidLoadable } from "@fluidframework/core-interfaces"; +import type { + IContainerRuntime, + IContainerRuntimeInternal, +} from "@fluidframework/container-runtime-definitions/internal"; +import type { + FluidObject, + FluidObjectKeys, + IFluidLoadable, +} from "@fluidframework/core-interfaces"; +import { assert } from "@fluidframework/core-utils/internal"; import type { IChannelFactory } from "@fluidframework/datastore-definitions/internal"; import type { IDirectory } from "@fluidframework/map/internal"; import type { IFluidDataStoreRegistry } from "@fluidframework/runtime-definitions/internal"; @@ -29,6 +37,7 @@ import type { CompatibilityMode, ContainerSchema, IRootDataObject, + IStaticEntryPoint, LoadableObjectKind, LoadableObjectKindRecord, LoadableObjectRecord, @@ -59,18 +68,22 @@ interface RootDataObjectProps { readonly initialObjects: LoadableObjectKindRecord; } +interface IProvideRootDataObject { + readonly RootDataObject: RootDataObject; +} + /** * The entry-point/root collaborative object of the {@link IFluidContainer | Fluid Container}. * Abstracts the dynamic code required to build a Fluid Container into a static representation for end customers. */ class RootDataObject extends DataObject<{ InitialState: RootDataObjectProps }> - implements IRootDataObject + implements IRootDataObject, IProvideRootDataObject { private readonly initialObjectsDirKey = "initial-objects-key"; private readonly _initialObjects: LoadableObjectRecord = {}; - public get IRootDataObject(): IRootDataObject { + public get RootDataObject(): RootDataObject { return this; } @@ -173,8 +186,9 @@ const rootDataStoreId = "rootDOId"; /** * Creates an {@link @fluidframework/aqueduct#BaseContainerRuntimeFactory} which constructs containers - * with a single IRootDataObject as their entry point, where the root data object's registry - * and initial objects are configured based on the provided schema (and optionally, data store registry). + * with an entry point containing single IRootDataObject (entry point is opaque to caller), + * where the root data object's registry and initial objects are configured based on the provided + * schema (and optionally, data store registry). * * @internal */ @@ -203,9 +217,35 @@ export function createDOProviderContainerRuntimeFactory(props: { ); } +function makeFluidObject = FluidObjectKeys>( + object: Omit, + providerKey: K, +): T { + return Object.defineProperty(object, providerKey, { value: object }) as T; +} + +async function provideEntryPoint( + containerRuntime: IContainerRuntime, +): Promise { + const entryPoint = await containerRuntime.getAliasedDataStoreEntryPoint(rootDataStoreId); + if (entryPoint === undefined) { + throw new Error(`default dataStore [${rootDataStoreId}] must exist`); + } + const rootDataObject = ((await entryPoint.get()) as FluidObject) + .RootDataObject; + assert(rootDataObject !== undefined, "entryPoint must be of type RootDataObject"); + return makeFluidObject( + { + rootDataObject, + extensionStore: containerRuntime as IContainerRuntimeInternal, + }, + "IStaticEntryPoint", + ); +} + /** - * Factory for Container Runtime instances that provide a single {@link IRootDataObject} - * as their entry point. + * Factory for Container Runtime instances that provide a {@link IStaticEntryPoint} + * (containing single {@link IRootDataObject}) as their entry point. */ class DOProviderContainerRuntimeFactory extends BaseContainerRuntimeFactory { private readonly rootDataObjectFactory: DataObjectFactory< @@ -239,16 +279,6 @@ class DOProviderContainerRuntimeFactory extends BaseContainerRuntimeFactory { { InitialState: RootDataObjectProps } >, ) { - const provideEntryPoint = async ( - containerRuntime: IContainerRuntime, - // eslint-disable-next-line unicorn/consistent-function-scoping - ): Promise => { - const entryPoint = await containerRuntime.getAliasedDataStoreEntryPoint(rootDataStoreId); - if (entryPoint === undefined) { - throw new Error(`default dataStore [${rootDataStoreId}] must exist`); - } - return entryPoint.get(); - }; super({ registryEntries: [rootDataObjectFactory.registryEntry], runtimeOptions: compatibilityModeRuntimeOptions[compatibilityMode], diff --git a/packages/framework/fluid-static/src/types.ts b/packages/framework/fluid-static/src/types.ts index 7ab069e83546..bbb525137846 100644 --- a/packages/framework/fluid-static/src/types.ts +++ b/packages/framework/fluid-static/src/types.ts @@ -4,6 +4,7 @@ */ import type { DataObjectKind } from "@fluidframework/aqueduct/internal"; +import type { ContainerExtensionStore } from "@fluidframework/container-runtime-definitions/internal"; import type { IEvent, IEventProvider, IFluidLoadable } from "@fluidframework/core-interfaces"; import type { SharedObjectKind } from "@fluidframework/shared-object-base"; import type { ISharedObjectKind } from "@fluidframework/shared-object-base/internal"; @@ -94,16 +95,12 @@ export interface ContainerSchema { readonly dynamicObjectTypes?: readonly SharedObjectKind[]; } -interface IProvideRootDataObject { - readonly IRootDataObject: IRootDataObject; -} - /** * Holds the collection of objects that the container was initially created with, as well as provides the ability * to dynamically create further objects during usage. * @internal */ -export interface IRootDataObject extends IProvideRootDataObject { +export interface IRootDataObject { /** * Provides a record of the initial objects defined on creation. */ @@ -119,6 +116,18 @@ export interface IRootDataObject extends IProvideRootDataObject { create(objectClass: SharedObjectKind): Promise; } +interface IProvideStaticEntryPoint { + readonly IStaticEntryPoint: IStaticEntryPoint; +} + +/** + * @internal + */ +export interface IStaticEntryPoint extends IProvideStaticEntryPoint { + readonly rootDataObject: IRootDataObject; + readonly extensionStore: ContainerExtensionStore; +} + /** * Signature for {@link IMember} change events. * diff --git a/packages/framework/presence/api-report/presence.alpha.api.md b/packages/framework/presence/api-report/presence.alpha.api.md index be5c3d878085..a4dafc4900fe 100644 --- a/packages/framework/presence/api-report/presence.alpha.api.md +++ b/packages/framework/presence/api-report/presence.alpha.api.md @@ -46,17 +46,17 @@ export interface BroadcastControlSettings { // @alpha export type ClientConnectionId = string; -// @alpha @sealed +// @alpha @sealed @deprecated export class ExperimentalPresenceDO { } -// @alpha +// @alpha @deprecated export const ExperimentalPresenceManager: SharedObjectKind; // @alpha export function getPresence(fluidContainer: IFluidContainer): Presence; -// @alpha +// @alpha @deprecated export function getPresenceViaDataObject(fluidLoadable: ExperimentalPresenceDO): Presence; // @alpha @system diff --git a/packages/framework/presence/package.json b/packages/framework/presence/package.json index 16c4b707c6f4..4f16256d19e5 100644 --- a/packages/framework/presence/package.json +++ b/packages/framework/presence/package.json @@ -22,10 +22,6 @@ "types": "./dist/alpha.d.ts", "default": "./dist/index.js" } - }, - "./internal/container-definitions/internal": { - "import": "./lib/container-definitions/index.js", - "require": "./dist/container-definitions/index.js" } }, "files": [ @@ -100,7 +96,6 @@ "dependencies": { "@fluid-internal/client-utils": "workspace:~", "@fluidframework/container-definitions": "workspace:~", - "@fluidframework/container-loader": "workspace:~", "@fluidframework/container-runtime-definitions": "workspace:~", "@fluidframework/core-interfaces": "workspace:~", "@fluidframework/core-utils": "workspace:~", diff --git a/packages/framework/presence/src/container-definitions/containerExtensions.ts b/packages/framework/presence/src/container-definitions/containerExtensions.ts deleted file mode 100644 index a3114590db5a..000000000000 --- a/packages/framework/presence/src/container-definitions/containerExtensions.ts +++ /dev/null @@ -1,162 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { - JsonDeserialized, - JsonSerializable, -} from "@fluidframework/core-interfaces/internal"; - -/** - * While connected, the id of a client within a session. - * - * @internal - */ -export type ClientConnectionId = string; - -/** - * Common interface between incoming and outgoing extension signals. - * - * @sealed - * @internal - */ -export interface IExtensionMessage { - /** - * Message type - */ - type: TType; - - /** - * Message content - */ - content: JsonDeserialized; - - /** - * The client ID that submitted the message. - * For server generated messages the clientId will be null. - */ - // eslint-disable-next-line @rushstack/no-new-null - clientId: ClientConnectionId | null; - - /** - * Client ID of the singular client the message is being (or has been) sent to. - * May only be specified when IConnect.supportedFeatures['submit_signals_v2'] is true, will throw otherwise. - */ - targetClientId?: ClientConnectionId; -} - -/** - * Defines requirements for a component to register with container as an extension. - * - * @internal - */ -export interface IContainerExtension { - /** - * Notifies the extension of a new use context. - * - * @param context - Context new reference to extension is acquired within - */ - onNewContext(...context: TContext): void; - - /** - * Callback for signal sent by this extension. - * - * @param address - Address of the signal - * @param signal - Signal content and metadata - * @param local - True if signal was sent by this client - */ - processSignal?(address: string, signal: IExtensionMessage, local: boolean): void; -} - -/** - * Defines the runtime interface an extension may access. - * In most cases this is a subset of {@link @fluidframework/container-runtime-definitions#IContainerRuntime}. - * - * @sealed - * @internal - */ -export interface IExtensionRuntime { - /** - * {@inheritdoc @fluidframework/container-runtime-definitions#IContainerRuntime.clientId} - */ - get clientId(): ClientConnectionId | undefined; - - /** - * Submits a signal to be sent to other clients. - * @param address - Custom address for the signal. - * @param type - Custom type of the signal. - * @param content - Custom content of the signal. Should be a JSON serializable object or primitive via {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify|JSON.stringify}. - * @param targetClientId - When specified, the signal is only sent to the provided client id. - * - * Upon receipt of signal, {@link IContainerExtension.processSignal} will be called with the same - * address, type, and content (less any non-{@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify|JSON.stringify}-able data). - */ - submitAddressedSignal( - address: string, - type: string, - content: JsonSerializable, - targetClientId?: ClientConnectionId, - ): void; -} - -/** - * Factory method to create an extension instance. - * - * Any such method provided to {@link ContainerExtensionStore.acquireExtension} - * must use the same value for a given {@link ContainerExtensionId} so that an - * `instanceof` check may be performed at runtime. - * - * @typeParam T - Type of extension to create - * @typeParam TContext - Array of optional custom context - * - * @param runtime - Runtime for extension to work against - * @param context - Custom context for extension. - * @returns Record providing: - * `interface` instance (type `T`) that is provided to caller of - * {@link ContainerExtensionStore.acquireExtension} and - * `extension` store/runtime uses to interact with extension. - * - * @internal - */ -export type ContainerExtensionFactory = new ( - runtime: IExtensionRuntime, - ...context: TContext -) => { readonly interface: T; readonly extension: IContainerExtension }; - -/** - * Unique identifier for extension - * - * @remarks - * A string known to all clients working with a certain ContainerExtension and unique - * among ContainerExtensions. Not `/` may be used in the string. Recommend using - * concatenation of: type of unique identifier, `:` (required), and unique identifier. - * - * @example Examples - * ```typescript - * "guid:g0fl001d-1415-5000-c00l-g0fa54g0b1g1" - * "name:@foo-scope_bar:v1" - * ``` - * - * @internal - */ -export type ContainerExtensionId = `${string}:${string}`; - -/** - * @sealed - * @internal - */ -export interface ContainerExtensionStore { - /** - * Acquires an extension from store or adds new one. - * - * @param id - Identifier for the requested extension - * @param factory - Factory to create the extension if not found - * @returns The extension - */ - acquireExtension( - id: ContainerExtensionId, - factory: ContainerExtensionFactory, - ...context: TContext - ): T; -} diff --git a/packages/framework/presence/src/container-definitions/index.ts b/packages/framework/presence/src/container-definitions/index.ts deleted file mode 100644 index 0a896ffee9f8..000000000000 --- a/packages/framework/presence/src/container-definitions/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -export type { IRuntimeInternal } from "./runtime.js"; - -// eslint-disable-next-line no-restricted-syntax -export type * from "./containerExtensions.js"; diff --git a/packages/framework/presence/src/container-definitions/runtime.ts b/packages/framework/presence/src/container-definitions/runtime.ts deleted file mode 100644 index 2157e5993520..000000000000 --- a/packages/framework/presence/src/container-definitions/runtime.ts +++ /dev/null @@ -1,13 +0,0 @@ -/*! - * Copyright (c) Microsoft Corporation and contributors. All rights reserved. - * Licensed under the MIT License. - */ - -import type { IRuntime } from "@fluidframework/container-definitions/internal"; - -import type { ContainerExtensionStore } from "./containerExtensions.js"; - -/** - * @internal - */ -export interface IRuntimeInternal extends IRuntime, ContainerExtensionStore {} diff --git a/packages/framework/presence/src/datastorePresenceManagerFactory.ts b/packages/framework/presence/src/datastorePresenceManagerFactory.ts index 8309199e3f56..31043475c5d3 100644 --- a/packages/framework/presence/src/datastorePresenceManagerFactory.ts +++ b/packages/framework/presence/src/datastorePresenceManagerFactory.ts @@ -7,6 +7,11 @@ * Hacky support for internal datastore based usages. */ +import { createEmitter } from "@fluid-internal/client-utils"; +import type { + ExtensionHostEvents, + RawInboundExtensionMessage, +} from "@fluidframework/container-runtime-definitions/internal"; import type { IFluidLoadable } from "@fluidframework/core-interfaces"; import { assert } from "@fluidframework/core-utils/internal"; import type { IInboundSignalMessage } from "@fluidframework/runtime-definitions/internal"; @@ -15,15 +20,21 @@ import type { SharedObjectKind } from "@fluidframework/shared-object-base"; import { BasicDataStoreFactory, LoadableFluidObject } from "./datastoreSupport.js"; import type { Presence } from "./presence.js"; import { createPresenceManager } from "./presenceManager.js"; +import type { + OutboundClientJoinMessage, + OutboundDatastoreUpdateMessage, + SignalMessages, +} from "./protocol.js"; -import type { IExtensionMessage } from "@fluidframework/presence/internal/container-definitions/internal"; - +/** + * This provides faux validation of the signal message. + */ function assertSignalMessageIsValid( - message: IInboundSignalMessage | IExtensionMessage, -): asserts message is IExtensionMessage { + message: IInboundSignalMessage | RawInboundExtensionMessage, +): asserts message is RawInboundExtensionMessage { assert(message.clientId !== null, 0xa58 /* Signal must have a client ID */); // The other difference between messages is that `content` for - // IExtensionMessage is JsonDeserialized and we are fine assuming that. + // RawInboundExtensionMessage is JsonDeserialized and we are fine assuming that. } /** @@ -38,10 +49,23 @@ class PresenceManagerDataObject extends LoadableFluidObject { if (!this._presenceManager) { // TODO: investigate if ContainerExtensionStore (path-based address routing for // Signals) is readily detectable here and use that presence manager directly. - const manager = createPresenceManager(this.runtime); + const runtime = this.runtime; + const events = createEmitter(); + runtime.on("connected", (clientId) => events.emit("connected", clientId)); + runtime.on("disconnected", () => events.emit("disconnected")); + + const manager = createPresenceManager({ + isConnected: () => runtime.connected, + getClientId: () => runtime.clientId, + events, + getQuorum: runtime.getQuorum.bind(runtime), + getAudience: runtime.getAudience.bind(runtime), + submitSignal: (message: OutboundClientJoinMessage | OutboundDatastoreUpdateMessage) => + runtime.submitSignal(message.type, message.content, message.targetClientId), + }); this.runtime.on("signal", (message: IInboundSignalMessage, local: boolean) => { assertSignalMessageIsValid(message); - manager.processSignal("", message, local); + manager.processSignal([], message, local); }); this._presenceManager = manager; } @@ -69,6 +93,7 @@ class PresenceManagerFactory { * @remarks * See {@link getPresenceViaDataObject} for example usage. * + * @deprecated Use {@link getPresence} instead. * @sealed * @alpha */ @@ -80,6 +105,7 @@ export declare class ExperimentalPresenceDO { * DataStore based Presence Manager that is used as fallback for preferred Container * Extension based version requires registration. Export SharedObjectKind for registration. * + * @deprecated Use {@link getPresence} instead. * @alpha */ export const ExperimentalPresenceManager = @@ -105,6 +131,7 @@ export const ExperimentalPresenceManager = * ); * ``` * + * @deprecated Use {@link getPresence} instead. * @alpha */ export function getPresenceViaDataObject(fluidLoadable: ExperimentalPresenceDO): Presence { diff --git a/packages/framework/presence/src/experimentalAccess.ts b/packages/framework/presence/src/experimentalAccess.ts index e478dc50e8e0..48009ea1b9c4 100644 --- a/packages/framework/presence/src/experimentalAccess.ts +++ b/packages/framework/presence/src/experimentalAccess.ts @@ -3,53 +3,56 @@ * Licensed under the MIT License. */ -import type { IContainerExperimental } from "@fluidframework/container-loader/internal"; +import type { + ContainerExtension, + ContainerExtensionFactory, + InboundExtensionMessage, +} from "@fluidframework/container-runtime-definitions/internal"; import { assert } from "@fluidframework/core-utils/internal"; import type { IFluidContainer } from "@fluidframework/fluid-static"; import { isInternalFluidContainer } from "@fluidframework/fluid-static/internal"; -import type { IContainerRuntimeBase } from "@fluidframework/runtime-definitions/internal"; -import type { IEphemeralRuntime } from "./internalTypes.js"; +import type { ExtensionHost, ExtensionRuntimeProperties } from "./internalTypes.js"; import type { Presence } from "./presence.js"; import type { PresenceExtensionInterface } from "./presenceManager.js"; import { createPresenceManager } from "./presenceManager.js"; - -import type { - ContainerExtensionStore, - IContainerExtension, - IExtensionMessage, - IExtensionRuntime, -} from "@fluidframework/presence/internal/container-definitions/internal"; - -function isContainerExtensionStore( - manager: ContainerExtensionStore | IContainerRuntimeBase | IContainerExperimental, -): manager is ContainerExtensionStore { - return (manager as ContainerExtensionStore).acquireExtension !== undefined; -} +import type { SignalMessages } from "./protocol.js"; /** * Common Presence manager for a container */ -class ContainerPresenceManager implements IContainerExtension { +class ContainerPresenceManager + implements + ContainerExtension, + InstanceType> +{ + // ContainerExtensionFactory return elements public readonly interface: Presence; public readonly extension = this; + private readonly manager: PresenceExtensionInterface; - public constructor(runtime: IExtensionRuntime) { - // TODO create the appropriate ephemeral runtime (map address must be in submitSignal, etc.) - this.interface = this.manager = createPresenceManager( - runtime as unknown as IEphemeralRuntime, - ); + public constructor(host: ExtensionHost) { + this.interface = this.manager = createPresenceManager({ + ...host, + submitSignal: (message) => { + host.submitAddressedSignal([], message); + }, + }); } - public onNewContext(): void { + public onNewUse(): void { // No-op } public static readonly extensionId = "dis:bb89f4c0-80fd-4f0c-8469-4f2848ee7f4a"; - public processSignal(address: string, message: IExtensionMessage, local: boolean): void { - this.manager.processSignal(address, message, local); + public processSignal( + addressChain: string[], + message: InboundExtensionMessage, + local: boolean, + ): void { + this.manager.processSignal(addressChain, message, local); } } @@ -65,14 +68,8 @@ export function getPresence(fluidContainer: IFluidContainer): Presence { isInternalFluidContainer(fluidContainer), 0xa2f /* IFluidContainer was not recognized. Only Containers generated by the Fluid Framework are supported. */, ); - const innerContainer = fluidContainer.container; - - assert( - isContainerExtensionStore(innerContainer), - 0xa39 /* Container does not support extensions. Use getPresenceViaDataObject. */, - ); - const presence = innerContainer.acquireExtension( + const presence = fluidContainer.acquireExtension( ContainerPresenceManager.extensionId, ContainerPresenceManager, ); diff --git a/packages/framework/presence/src/internalTypes.ts b/packages/framework/presence/src/internalTypes.ts index 5af893f7695b..57b59019c043 100644 --- a/packages/framework/presence/src/internalTypes.ts +++ b/packages/framework/presence/src/internalTypes.ts @@ -3,13 +3,27 @@ * Licensed under the MIT License. */ -import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; -import type { IFluidDataStoreRuntime } from "@fluidframework/datastore-definitions/internal"; +import type { ExtensionHost as ContainerExtensionHost } from "@fluidframework/container-runtime-definitions/internal"; import type { InternalTypes } from "./exposedInternalTypes.js"; import type { AttendeeId, Attendee } from "./presence.js"; +import type { + OutboundClientJoinMessage, + OutboundDatastoreUpdateMessage, + SignalMessages, +} from "./protocol.js"; -import type { IRuntimeInternal } from "@fluidframework/presence/internal/container-definitions/internal"; +/** + * @internal + */ +export interface ExtensionRuntimeProperties { + SignalMessages: SignalMessages; +} +/** + * Presence specific ExtensionHost + * @internal + */ +export type ExtensionHost = ContainerExtensionHost; /** * @internal @@ -22,19 +36,27 @@ export interface ClientRecord & - Partial>; +export type IEphemeralRuntime = Omit & + // Apart from tests, there is always a logger. So this could be promoted to required. + Partial> & { + /** + * Submits the signal to be sent to other clients. + * @param type - Type of the signal. + * @param content - Content of the signal. Should be a JSON serializable object or primitive. + * @param targetClientId - When specified, the signal is only sent to the provided client id. + */ + submitSignal: ( + message: OutboundClientJoinMessage | OutboundDatastoreUpdateMessage, + ) => void; + }; /** * @internal diff --git a/packages/framework/presence/src/internalUtils.ts b/packages/framework/presence/src/internalUtils.ts index dc52e39c563d..8a078191adb0 100644 --- a/packages/framework/presence/src/internalUtils.ts +++ b/packages/framework/presence/src/internalUtils.ts @@ -25,9 +25,14 @@ type RequiredAndNotUndefined = { /** * Object.entries retyped to preserve known keys and their types. * + * @privateRemarks + * The is a defect in this utility when a string index appears in the object. + * In such a case, the only result is `[string, T]`, where `T` is the type + * of the string index entry. + * * @internal */ -export const objectEntries = Object.entries as (o: T) => KeyValuePairs; +export const objectEntries = Object.entries as (o: T) => KeyValuePairs; /** * Object.entries retyped to preserve known keys and their types. @@ -39,7 +44,7 @@ export const objectEntries = Object.entries as (o: T) => KeyValuePairs; * * @internal */ -export const objectEntriesWithoutUndefined = Object.entries as ( +export const objectEntriesWithoutUndefined = Object.entries as ( o: T, ) => KeyValuePairs>; @@ -48,7 +53,9 @@ export const objectEntriesWithoutUndefined = Object.entries as ( * * @internal */ -export const objectKeys = Object.keys as (o: T) => (keyof MapNumberIndicesToStrings)[]; +export const objectKeys = Object.keys as ( + o: T, +) => (keyof MapNumberIndicesToStrings)[]; /** * Retrieve a value from a record with the given key, or create a new entry if @@ -62,7 +69,7 @@ export const objectKeys = Object.keys as (o: T) => (keyof MapNumberIndicesToS * @returns either the existing value for the given key, or the newly-created * value (the result of `defaultValue`) */ -export function getOrCreateRecord( +export function getOrCreateRecord( record: Record, key: K, defaultValue: (key: K) => V, diff --git a/packages/framework/presence/src/presenceDatastoreManager.ts b/packages/framework/presence/src/presenceDatastoreManager.ts index bb64e27f9424..26291e92c8c6 100644 --- a/packages/framework/presence/src/presenceDatastoreManager.ts +++ b/packages/framework/presence/src/presenceDatastoreManager.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. */ +import type { InboundExtensionMessage } from "@fluidframework/container-runtime-definitions/internal"; import type { IEmitter } from "@fluidframework/core-interfaces/internal"; import { assert } from "@fluidframework/core-utils/internal"; -import type { IInboundSignalMessage } from "@fluidframework/runtime-definitions/internal"; import type { ITelemetryLoggerExt } from "@fluidframework/telemetry-utils/internal"; import type { ClientConnectionId } from "./baseTypes.js"; @@ -24,6 +24,15 @@ import { mergeUntrackedDatastore, mergeValueDirectory, } from "./presenceStates.js"; +import type { + GeneralDatastoreMessageContent, + InboundClientJoinMessage, + InboundDatastoreUpdateMessage, + OutboundDatastoreUpdateMessage, + SignalMessages, + SystemDatastore, +} from "./protocol.js"; +import { datastoreUpdateMessageType, joinMessageType } from "./protocol.js"; import type { SystemWorkspaceDatastore } from "./systemWorkspace.js"; import { TimerManager } from "./timerManager.js"; import type { @@ -35,66 +44,29 @@ import type { WorkspaceAddress, } from "./types.js"; -import type { IExtensionMessage } from "@fluidframework/presence/internal/container-definitions/internal"; - interface AnyWorkspaceEntry { public: AnyWorkspace; internal: PresenceStatesInternal; } -interface SystemDatastore { - "system:presence": SystemWorkspaceDatastore; -} - -type InternalWorkspaceAddress = `${"s" | "n"}:${WorkspaceAddress}`; - type PresenceDatastore = SystemDatastore & { [WorkspaceAddress: string]: ValueElementMap; }; -interface GeneralDatastoreMessageContent { - [WorkspaceAddress: string]: { - [StateValueManagerKey: string]: { - [AttendeeId: AttendeeId]: ClientUpdateEntry; - }; - }; -} - -type DatastoreMessageContent = SystemDatastore & GeneralDatastoreMessageContent; - -const datastoreUpdateMessageType = "Pres:DatastoreUpdate"; +type InternalWorkspaceAddress = `${"s" | "n"}:${WorkspaceAddress}`; const internalWorkspaceTypes: Readonly> = { s: "States", n: "Notifications", } as const; -interface DatastoreUpdateMessage extends IInboundSignalMessage { - type: typeof datastoreUpdateMessageType; - content: { - sendTimestamp: number; - avgLatency: number; - isComplete?: true; - data: DatastoreMessageContent; - }; -} - -const joinMessageType = "Pres:ClientJoin"; -interface ClientJoinMessage extends IInboundSignalMessage { - type: typeof joinMessageType; - content: { - updateProviders: ClientConnectionId[]; - sendTimestamp: number; - avgLatency: number; - data: DatastoreMessageContent; - }; -} - +const knownMessageTypes = new Set([joinMessageType, datastoreUpdateMessageType]); function isPresenceMessage( - message: IInboundSignalMessage, -): message is DatastoreUpdateMessage | ClientJoinMessage { - return message.type.startsWith("Pres:"); + message: InboundExtensionMessage, +): message is InboundDatastoreUpdateMessage | InboundClientJoinMessage { + return knownMessageTypes.has(message.type); } + /** * @internal */ @@ -109,7 +81,11 @@ export interface PresenceDatastoreManager { internalWorkspaceAddress: `n:${WorkspaceAddress}`, requestedContent: TSchema, ): NotificationsWorkspace; - processSignal(message: IExtensionMessage, local: boolean): void; + processSignal( + message: InboundExtensionMessage, + local: boolean, + optional: boolean, + ): void; } function mergeGeneralDatastoreMessageContent( @@ -183,12 +159,15 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { if (updateProviders.length > 3) { updateProviders.length = 3; } - this.runtime.submitSignal(joinMessageType, { - sendTimestamp: Date.now(), - avgLatency: this.averageLatency, - data: this.datastore, - updateProviders, - } satisfies ClientJoinMessage["content"]); + this.runtime.submitSignal({ + type: joinMessageType, + content: { + sendTimestamp: Date.now(), + avgLatency: this.averageLatency, + data: this.datastore, + updateProviders, + }, + }); } public getWorkspace( @@ -212,7 +191,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { options: RuntimeLocalUpdateOptions, ): void => { // Check for connectivity before sending updates. - if (!this.runtime.connected) { + if (!this.runtime.isConnected()) { return; } @@ -304,14 +283,14 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { } // Check for connectivity before sending updates. - if (!this.runtime.connected) { + if (!this.runtime.isConnected()) { // Clear the queued data since we're disconnected. We don't want messages // to queue infinitely while disconnected. this.queuedData = undefined; return; } - const clientConnectionId = this.runtime.clientId; + const clientConnectionId = this.runtime.getClientId(); assert(clientConnectionId !== undefined, 0xa59 /* Client connected without clientId */); const currentClientToSessionValueState = // When connected, `clientToSessionId` must always have current connection entry. @@ -334,34 +313,33 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { }, ...this.queuedData, }, - } satisfies DatastoreUpdateMessage["content"]; + } satisfies OutboundDatastoreUpdateMessage["content"]; this.queuedData = undefined; - this.runtime.submitSignal(datastoreUpdateMessageType, newMessage); + this.runtime.submitSignal({ type: datastoreUpdateMessageType, content: newMessage }); } private broadcastAllKnownState(): void { - this.runtime.submitSignal(datastoreUpdateMessageType, { - sendTimestamp: Date.now(), - avgLatency: this.averageLatency, - isComplete: true, - data: this.datastore, - } satisfies DatastoreUpdateMessage["content"]); + this.runtime.submitSignal({ + type: datastoreUpdateMessageType, + content: { + sendTimestamp: Date.now(), + avgLatency: this.averageLatency, + isComplete: true, + data: this.datastore, + }, + }); this.refreshBroadcastRequested = false; } public processSignal( - // Note: IInboundSignalMessage is used here in place of IExtensionMessage - // as IExtensionMessage's strictly JSON `content` creates type compatibility - // issues with `AttendeeId` keys and really unknown value content. - // IExtensionMessage is a subset of IInboundSignalMessage so this is safe. - // Change types of DatastoreUpdateMessage | ClientJoinMessage to - // IExtensionMessage<> derivatives to see the issues. - message: IInboundSignalMessage | DatastoreUpdateMessage | ClientJoinMessage, + message: InboundExtensionMessage, local: boolean, + optional: boolean, ): void { const received = Date.now(); assert(message.clientId !== null, 0xa3a /* Map received signal without clientId */); if (!isPresenceMessage(message)) { + assert(optional, "Unrecognized message type in critical message"); return; } if (local) { @@ -385,13 +363,12 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { // It is possible for some signals to come in while client is not connected due // to how work is scheduled. If we are not connected, we can't respond to the // join request. We will make our own Join request once we are connected. - if (this.runtime.connected) { + if (this.runtime.isConnected()) { this.prepareJoinResponse(message.content.updateProviders, message.clientId); } // It is okay to continue processing the contained updates even if we are not // connected. } else { - assert(message.type === datastoreUpdateMessageType, 0xa3b /* Unexpected message type */); if (message.content.isComplete) { this.refreshBroadcastRequested = false; } @@ -424,7 +401,10 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { } const postUpdateActions: PostUpdateAction[] = []; - for (const [workspaceAddress, remoteDatastore] of Object.entries(message.content.data)) { + // While the system workspace is processed here too, it is declared as + // conforming to the general schema. So drop its override. + const data = message.content.data as Omit; + for (const [workspaceAddress, remoteDatastore] of Object.entries(data)) { // Direct to the appropriate Presence Workspace, if present. const workspace = this.workspaces.get(workspaceAddress); if (workspace) { @@ -472,7 +452,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { // We must be connected to receive this message, so clientId should be defined. // If it isn't then, not really a problem; just won't be in provider or quorum list. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const clientId = this.runtime.clientId!; + const clientId = this.runtime.getClientId()!; // const requestor = message.clientId; if (updateProviders.includes(clientId)) { // Send all current state to the new client @@ -513,7 +493,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { setTimeout(() => { // Make sure a broadcast is still needed and we are currently connected. // If not connected, nothing we can do. - if (this.refreshBroadcastRequested && this.runtime.connected) { + if (this.refreshBroadcastRequested && this.runtime.isConnected()) { this.broadcastAllKnownState(); this.logger?.sendTelemetryEvent({ eventName: "JoinResponse", diff --git a/packages/framework/presence/src/presenceManager.ts b/packages/framework/presence/src/presenceManager.ts index 980054084e36..3c4f105f923e 100644 --- a/packages/framework/presence/src/presenceManager.ts +++ b/packages/framework/presence/src/presenceManager.ts @@ -4,6 +4,10 @@ */ import { createEmitter } from "@fluid-internal/client-utils"; +import type { + ContainerExtension, + InboundExtensionMessage, +} from "@fluidframework/container-runtime-definitions/internal"; import type { IEmitter, Listenable } from "@fluidframework/core-interfaces/internal"; import { createSessionId } from "@fluidframework/id-compressor/internal"; import type { @@ -14,10 +18,11 @@ import { createChildMonitoringContext } from "@fluidframework/telemetry-utils/in import type { ClientConnectionId } from "./baseTypes.js"; import type { BroadcastControlSettings } from "./broadcastControls.js"; -import type { IEphemeralRuntime } from "./internalTypes.js"; +import type { ExtensionRuntimeProperties, IEphemeralRuntime } from "./internalTypes.js"; import type { AttendeesEvents, AttendeeId, Presence, PresenceEvents } from "./presence.js"; import type { PresenceDatastoreManager } from "./presenceDatastoreManager.js"; import { PresenceDatastoreManagerImpl } from "./presenceDatastoreManager.js"; +import type { SignalMessages } from "./protocol.js"; import type { SystemWorkspace, SystemWorkspaceDatastore } from "./systemWorkspace.js"; import { createSystemWorkspace } from "./systemWorkspace.js"; import type { @@ -28,18 +33,13 @@ import type { WorkspaceAddress, } from "./types.js"; -import type { - IContainerExtension, - IExtensionMessage, -} from "@fluidframework/presence/internal/container-definitions/internal"; - /** - * Portion of the container extension requirements ({@link IContainerExtension}) that are delegated to presence manager. + * Portion of the container extension requirements ({@link ContainerExtension}) that are delegated to presence manager. * * @internal */ export type PresenceExtensionInterface = Required< - Pick, "processSignal"> + Pick, "processSignal"> >; /** @@ -87,11 +87,12 @@ class PresenceManager implements Presence, PresenceExtensionInterface { ); this.attendees = this.systemWorkspace; - runtime.on("connected", this.onConnect.bind(this)); + runtime.events.on("connected", this.onConnect.bind(this)); - runtime.on("disconnected", () => { - if (runtime.clientId !== undefined) { - this.removeClientConnectionId(runtime.clientId); + runtime.events.on("disconnected", () => { + const currentClientId = runtime.getClientId(); + if (currentClientId !== undefined) { + this.removeClientConnectionId(currentClientId); } }); @@ -103,8 +104,8 @@ class PresenceManager implements Presence, PresenceExtensionInterface { // delayed we expect that "connected" event has passed. // Note: In some manual testing, this does not appear to be enough to // always trigger an initial connect. - const clientId = runtime.clientId; - if (clientId !== undefined && runtime.connected) { + const clientId = runtime.getClientId(); + if (clientId !== undefined && runtime.isConnected()) { this.onConnect(clientId); } } @@ -120,12 +121,20 @@ class PresenceManager implements Presence, PresenceExtensionInterface { /** * Check for Presence message and process it. * - * @param address - Address of the message - * @param message - Message to be processed + * @param addressChain - Address chain of the message + * @param message - Unverified message to be processed * @param local - Whether the message originated locally (`true`) or remotely (`false`) */ - public processSignal(address: string, message: IExtensionMessage, local: boolean): void { - this.datastoreManager.processSignal(message, local); + public processSignal( + addressChain: string[], + message: InboundExtensionMessage, + local: boolean, + ): void { + this.datastoreManager.processSignal( + message, + local, + /* optional */ addressChain[0] === "?", + ); } } diff --git a/packages/framework/presence/src/protocol.ts b/packages/framework/presence/src/protocol.ts new file mode 100644 index 000000000000..d4259a223513 --- /dev/null +++ b/packages/framework/presence/src/protocol.ts @@ -0,0 +1,88 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { + OutboundExtensionMessage, + VerifiedInboundExtensionMessage, +} from "@fluidframework/container-runtime-definitions/internal"; + +import type { ClientConnectionId } from "./baseTypes.js"; +import type { AttendeeId } from "./presence.js"; +import type { ClientUpdateEntry } from "./presenceStates.js"; +import type { SystemWorkspaceDatastore } from "./systemWorkspace.js"; + +/** + * @internal + */ +export interface SystemDatastore { + "system:presence": SystemWorkspaceDatastore; +} + +/** + * @internal + */ +export interface GeneralDatastoreMessageContent { + [WorkspaceAddress: string]: { + [StateValueManagerKey: string]: { + [AttendeeId: AttendeeId]: ClientUpdateEntry; + }; + }; +} + +type DatastoreMessageContent = GeneralDatastoreMessageContent & SystemDatastore; + +/** + * @internal + */ +export const datastoreUpdateMessageType = "Pres:DatastoreUpdate"; +interface DatastoreUpdateMessage { + type: typeof datastoreUpdateMessageType; + content: { + sendTimestamp: number; + avgLatency: number; + isComplete?: true; + data: DatastoreMessageContent; + }; +} + +/** + * @internal + */ +export type OutboundDatastoreUpdateMessage = OutboundExtensionMessage; + +/** + * @internal + */ +export type InboundDatastoreUpdateMessage = + VerifiedInboundExtensionMessage; + +/** + * @internal + */ +export const joinMessageType = "Pres:ClientJoin"; +interface ClientJoinMessage { + type: typeof joinMessageType; + content: { + updateProviders: ClientConnectionId[]; + sendTimestamp: number; + avgLatency: number; + data: DatastoreMessageContent; + }; +} + +/** + * @internal + */ +export type OutboundClientJoinMessage = OutboundExtensionMessage; + +/** + * @internal + */ +export type InboundClientJoinMessage = VerifiedInboundExtensionMessage; + +/** + * @internal + */ +export type SignalMessages = ClientJoinMessage | DatastoreUpdateMessage; diff --git a/packages/framework/presence/src/test/batching.spec.ts b/packages/framework/presence/src/test/batching.spec.ts index d578df18c78f..fa74fefc29c5 100644 --- a/packages/framework/presence/src/test/batching.spec.ts +++ b/packages/framework/presence/src/test/batching.spec.ts @@ -12,7 +12,12 @@ import { Notifications, StateFactory } from "../index.js"; import type { createPresenceManager } from "../presenceManager.js"; import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; -import { assertFinalExpectations, prepareConnectedPresence } from "./testUtils.js"; +import { + assertFinalExpectations, + connectionId2, + prepareConnectedPresence, + attendeeId2, +} from "./testUtils.js"; describe("Presence", () => { describe("batching", () => { @@ -35,7 +40,7 @@ describe("Presence", () => { clock.setSystemTime(initialTime); // Set up the presence connection. - presence = prepareConnectedPresence(runtime, "attendeeId-2", "client2", clock, logger); + presence = prepareConnectedPresence(runtime, attendeeId2, connectionId2, clock, logger); }); afterEach(() => { @@ -53,27 +58,29 @@ describe("Presence", () => { it("sends signal immediately when allowable latency is 0", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1010, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1010, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 0, - "timestamp": 1010, - "value": { - "num": 0, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 0, + "timestamp": 1010, + "value": { + "num": 0, + }, }, }, }, @@ -82,27 +89,29 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1020, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1020, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 1, - "timestamp": 1020, - "value": { - "num": 42, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 1, + "timestamp": 1020, + "value": { + "num": 42, + }, }, }, }, @@ -111,27 +120,29 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1020, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1020, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1020, - "value": { - "num": 84, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1020, + "value": { + "num": 84, + }, }, }, }, @@ -165,27 +176,29 @@ describe("Presence", () => { it("sets timer for default allowableUpdateLatency", async () => { runtime.signalsExpected.push([ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1070, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1070, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 0, - "timestamp": 1010, - "value": { - "num": 0, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 0, + "timestamp": 1010, + "value": { + "num": 0, + }, }, }, }, @@ -211,27 +224,29 @@ describe("Presence", () => { it("batches signals sent within default allowableUpdateLatency", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1070, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1070, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 3, - "timestamp": 1060, - "value": { - "num": 22, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 3, + "timestamp": 1060, + "value": { + "num": 22, + }, }, }, }, @@ -240,27 +255,29 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1150, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1150, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 6, - "timestamp": 1140, - "value": { - "num": 90, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 6, + "timestamp": 1140, + "value": { + "num": 90, + }, }, }, }, @@ -315,27 +332,29 @@ describe("Presence", () => { it("batches signals sent within a specified allowableUpdateLatency", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1110, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1110, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1100, - "value": { - "num": 34, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1100, + "value": { + "num": 34, + }, }, }, }, @@ -344,27 +363,29 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1240, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1240, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 5, - "timestamp": 1220, - "value": { - "num": 90, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 5, + "timestamp": 1220, + "value": { + "num": 90, + }, }, }, }, @@ -417,36 +438,38 @@ describe("Presence", () => { it("queued signal is sent immediately with immediate update message", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1010, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1010, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 0, - "timestamp": 1010, - "value": { - "num": 0, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 0, + "timestamp": 1010, + "value": { + "num": 0, + }, }, }, - }, - "immediateUpdate": { - "attendeeId-2": { - "rev": 0, - "timestamp": 1010, - "value": { - "num": 0, + "immediateUpdate": { + [attendeeId2]: { + "rev": 0, + "timestamp": 1010, + "value": { + "num": 0, + }, }, }, }, @@ -455,36 +478,38 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1110, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1110, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1100, - "value": { - "num": 34, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1100, + "value": { + "num": 34, + }, }, }, - }, - "immediateUpdate": { - "attendeeId-2": { - "rev": 1, - "timestamp": 1110, - "value": { - "num": 56, + "immediateUpdate": { + [attendeeId2]: { + "rev": 1, + "timestamp": 1110, + "value": { + "num": 56, + }, }, }, }, @@ -527,36 +552,38 @@ describe("Presence", () => { it("batches signals with different allowed latencies", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1060, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1060, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1050, - "value": { - "num": 34, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1050, + "value": { + "num": 34, + }, }, }, - }, - "note": { - "attendeeId-2": { - "rev": 1, - "timestamp": 1020, - "value": { - "message": "will be queued", + "note": { + [attendeeId2]: { + "rev": 1, + "timestamp": 1020, + "value": { + "message": "will be queued", + }, }, }, }, @@ -565,22 +592,24 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1110, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { "rev": 0, "timestamp": 1000, "value": "attendeeId-2" }, - }, - }, - "s:name:testStateWorkspace": { - "note": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1060, - "value": { "message": "final message" }, + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1110, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { "rev": 0, "timestamp": 1000, "value": attendeeId2 }, + }, + }, + "s:name:testStateWorkspace": { + "note": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1060, + "value": { "message": "final message" }, + }, }, }, }, @@ -627,38 +656,40 @@ describe("Presence", () => { it("batches signals from multiple workspaces", async () => { runtime.signalsExpected.push([ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1070, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1070, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1050, - "value": { - "num": 34, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1050, + "value": { + "num": 34, + }, }, }, }, - }, - "s:name:testStateWorkspace2": { - "note": { - "attendeeId-2": { - "rev": 2, - "timestamp": 1060, - "value": { - "message": "final message", + "s:name:testStateWorkspace2": { + "note": { + [attendeeId2]: { + "rev": 2, + "timestamp": 1060, + "value": { + "message": "final message", + }, }, }, }, @@ -708,23 +739,25 @@ describe("Presence", () => { it("notification signals are sent immediately", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1050, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { "rev": 0, "timestamp": 1000, "value": "attendeeId-2" }, - }, - }, - "n:name:testNotificationWorkspace": { - "testEvents": { - "attendeeId-2": { - "rev": 0, - "timestamp": 0, - "value": { "name": "newId", "args": [77] }, - "ignoreUnmonitored": true, + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1050, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { "rev": 0, "timestamp": 1000, "value": attendeeId2 }, + }, + }, + "n:name:testNotificationWorkspace": { + "testEvents": { + [attendeeId2]: { + "rev": 0, + "timestamp": 0, + "value": { "name": "newId", "args": [77] }, + "ignoreUnmonitored": true, + }, }, }, }, @@ -732,23 +765,25 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1060, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { "rev": 0, "timestamp": 1000, "value": "attendeeId-2" }, - }, - }, - "n:name:testNotificationWorkspace": { - "testEvents": { - "attendeeId-2": { - "rev": 0, - "timestamp": 0, - "value": { "name": "newId", "args": [88] }, - "ignoreUnmonitored": true, + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1060, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { "rev": 0, "timestamp": 1000, "value": attendeeId2 }, + }, + }, + "n:name:testNotificationWorkspace": { + "testEvents": { + [attendeeId2]: { + "rev": 0, + "timestamp": 0, + "value": { "name": "newId", "args": [88] }, + "ignoreUnmonitored": true, + }, }, }, }, @@ -792,41 +827,43 @@ describe("Presence", () => { it("notification signals cause queued messages to be sent immediately", async () => { runtime.signalsExpected.push( [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1060, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1060, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "s:name:testStateWorkspace": { - "count": { - "attendeeId-2": { - "rev": 3, - "timestamp": 1040, - "value": { - "num": 56, + "s:name:testStateWorkspace": { + "count": { + [attendeeId2]: { + "rev": 3, + "timestamp": 1040, + "value": { + "num": 56, + }, }, }, }, - }, - "n:name:testNotificationWorkspace": { - "testEvents": { - "attendeeId-2": { - "rev": 0, - "timestamp": 0, - "value": { - "name": "newId", - "args": [99], + "n:name:testNotificationWorkspace": { + "testEvents": { + [attendeeId2]: { + "rev": 0, + "timestamp": 0, + "value": { + "name": "newId", + "args": [99], + }, + "ignoreUnmonitored": true, }, - "ignoreUnmonitored": true, }, }, }, @@ -834,30 +871,32 @@ describe("Presence", () => { }, ], [ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1090, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": 1000, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1090, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": 1000, + "value": attendeeId2, + }, }, }, - }, - "n:name:testNotificationWorkspace": { - "testEvents": { - "attendeeId-2": { - "rev": 0, - "timestamp": 0, - "value": { - "name": "newId", - "args": [111], + "n:name:testNotificationWorkspace": { + "testEvents": { + [attendeeId2]: { + "rev": 0, + "timestamp": 0, + "value": { + "name": "newId", + "args": [111], + }, + "ignoreUnmonitored": true, }, - "ignoreUnmonitored": true, }, }, }, diff --git a/packages/framework/presence/src/test/eventing.spec.ts b/packages/framework/presence/src/test/eventing.spec.ts index 4fc11d8c2e5b..104dd24d2090 100644 --- a/packages/framework/presence/src/test/eventing.spec.ts +++ b/packages/framework/presence/src/test/eventing.spec.ts @@ -12,7 +12,11 @@ import { useFakeTimers, spy } from "sinon"; import type { Attendee, WorkspaceAddress } from "../index.js"; import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; -import { assertFinalExpectations, prepareConnectedPresence } from "./testUtils.js"; +import { + assertFinalExpectations, + prepareConnectedPresence, + attendeeId1, +} from "./testUtils.js"; import type { LatestRaw, @@ -31,13 +35,13 @@ const attendeeUpdate = { "client1": { "rev": 0, "timestamp": 0, - "value": "attendeeId-1", + "value": attendeeId1, }, }, } as const; const latestUpdate = { "latest": { - "attendeeId-1": { + [attendeeId1]: { "rev": 1, "timestamp": 0, "value": { x: 1, y: 1, z: 1 }, @@ -46,7 +50,7 @@ const latestUpdate = { } as const; const latestMapUpdate = { "latestMap": { - "attendeeId-1": { + [attendeeId1]: { "rev": 1, "items": { "key1": { @@ -65,7 +69,7 @@ const latestMapUpdate = { } as const; const latestUpdateRev2 = { "latest": { - "attendeeId-1": { + [attendeeId1]: { "rev": 2, "timestamp": 50, "value": { x: 2, y: 2, z: 2 }, @@ -74,7 +78,7 @@ const latestUpdateRev2 = { } as const; const itemRemovedMapUpdate = { "latestMap": { - "attendeeId-1": { + [attendeeId1]: { "rev": 2, "items": { "key2": { @@ -87,7 +91,7 @@ const itemRemovedMapUpdate = { } as const; const itemRemovedAndItemUpdatedMapUpdate = { "latestMap": { - "attendeeId-1": { + [attendeeId1]: { "rev": 2, "items": { "key2": { @@ -105,7 +109,7 @@ const itemRemovedAndItemUpdatedMapUpdate = { }; const itemUpdatedAndItemRemoveddMapUpdate = { "latestMap": { - "attendeeId-1": { + [attendeeId1]: { "rev": 2, "items": { "key1": { @@ -127,7 +131,7 @@ const latestMapItemRemovedAndLatestUpdate = { } as const; const notificationsUpdate = { "testEvents": { - "attendeeId-1": { + [attendeeId1]: { "rev": 0, "timestamp": 0, "value": { "name": "newId", "args": [42] }, @@ -294,7 +298,7 @@ describe("Presence", () => { const updates = { "system:presence": attendeeUpdate, ...valueManagerUpdates }; presence.processSignal( - "", + [], { type: datastoreUpdateType, content: { diff --git a/packages/framework/presence/src/test/mockEphemeralRuntime.ts b/packages/framework/presence/src/test/mockEphemeralRuntime.ts index f966c14a04cc..127e44dc229b 100644 --- a/packages/framework/presence/src/test/mockEphemeralRuntime.ts +++ b/packages/framework/presence/src/test/mockEphemeralRuntime.ts @@ -67,6 +67,8 @@ function makeMockAudience(clients: ClientData[]): MockAudience { * Mock ephemeral runtime for testing */ export class MockEphemeralRuntime implements IEphemeralRuntime { + public clientId: string | undefined; + public connected: boolean = false; public logger?: ITelemetryBaseLogger; public readonly quorum: MockQuorumClients; public readonly audience: MockAudience; @@ -95,31 +97,36 @@ export class MockEphemeralRuntime implements IEphemeralRuntime { /* count of write clients (in quorum) */ 6, ); this.quorum = makeMockQuorum(clientsData); - this.getQuorum = () => this.quorum; this.audience = makeMockAudience(clientsData); - this.getAudience = () => this.audience; - this.on = ( - event: string, - listener: (...args: any[]) => void, - // Events style eventing does not lend itself to union that - // IEphemeralRuntime is derived from, so we are using `any` here - // but meet the intent of the interface. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): any => { - if (!this.isSupportedEvent(event)) { - throw new Error(`Event ${event} is not supported`); - } - // Switch to allowing a single listener as commented when - // implementation uses a single "connected" listener. - // if (this.listeners[event]) { - // throw new Error(`Event ${event} already has a listener`); - // } - // this.listeners[event] = listener; - if (this.listeners[event].length > 1) { - throw new Error(`Event ${event} already has multiple listeners`); - } - this.listeners[event].push(listener); - return this; + this.events = { + on: ( + event: string, + listener: (...args: any[]) => void, + // Events style eventing does not lend itself to union that + // IEphemeralRuntime is derived from, so we are using `any` here + // but meet the intent of the interface. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => { + if (!this.isSupportedEvent(event)) { + throw new Error(`Event ${event} is not supported`); + } + // Switch to allowing a single listener as commented when + // implementation uses a single "connected" listener. + // if (this.listeners[event]) { + // throw new Error(`Event ${event} already has a listener`); + // } + // this.listeners[event] = listener; + if (this.listeners[event].length > 1) { + throw new Error(`Event ${event} already has multiple listeners`); + } + this.listeners[event].push(listener); + return this; + }, + off: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): any => { + throw new Error("IEphemeralRuntime.off method not implemented."); + }, }; } @@ -129,8 +136,8 @@ export class MockEphemeralRuntime implements IEphemeralRuntime { 0, `Missing signals [\n${this.signalsExpected .map( - (a) => - `\t{ type: ${a[0]}, content: ${JSON.stringify(a[1], undefined, "\t")}, targetClientId: ${a[2]} }`, + ([m]) => + `\t{ type: ${m.type}, content: ${JSON.stringify(m.content, undefined, "\t")}, targetClientId: ${m.targetClientId} }`, ) .join(",\n\t")}\n]`, ); @@ -162,24 +169,15 @@ export class MockEphemeralRuntime implements IEphemeralRuntime { // #region IEphemeralRuntime - public clientId: string | undefined; - public connected: boolean = false; - - public on: IEphemeralRuntime["on"]; - - public off: IEphemeralRuntime["off"] = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ): any => { - throw new Error("IEphemeralRuntime.off method not implemented."); - }; + public isConnected = (): ReturnType => this.connected; + public getClientId = (): ReturnType => this.clientId; - public getAudience: () => ReturnType; + public events: IEphemeralRuntime["events"]; - public getQuorum: () => ReturnType; + public getQuorum: () => ReturnType = () => this.quorum; + public getAudience: () => ReturnType = () => this.audience; - public submitSignal: IEphemeralRuntime["submitSignal"] = ( - ...args: Parameters - ) => { + public submitSignal: IEphemeralRuntime["submitSignal"] = (...args: unknown[]) => { if (this.signalsExpected.length === 0) { throw new Error(`Unexpected signal: ${JSON.stringify(args)}`); } diff --git a/packages/framework/presence/src/test/notificationsManager.spec.ts b/packages/framework/presence/src/test/notificationsManager.spec.ts index 43e9f8aebbf5..4d92ba58772a 100644 --- a/packages/framework/presence/src/test/notificationsManager.spec.ts +++ b/packages/framework/presence/src/test/notificationsManager.spec.ts @@ -16,10 +16,17 @@ import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; import { assertFinalExpectations, assertIdenticalTypes, + connectionId2, createInstanceOf, + createSpecificAttendeeId, prepareConnectedPresence, + attendeeId2, } from "./testUtils.js"; +const attendeeId3 = createSpecificAttendeeId("attendeeId-3"); +// Really a ClientConnectionId, but typed as AttendeeId for TypeScript workaround. +const connectionId3 = createSpecificAttendeeId("client3"); + describe("Presence", () => { describe("NotificationsManager", () => { // Note: this test setup mimics the setup in src/test/presenceManager.spec.ts @@ -112,23 +119,25 @@ describe("Presence", () => { clock.tick(10); runtime.signalsExpected.push([ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1020, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { "rev": 0, "timestamp": 1000, "value": "attendeeId-2" }, + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1020, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { "rev": 0, "timestamp": 1000, "value": attendeeId2 }, + }, }, - }, - "n:name:testNotificationWorkspace": { - "testEvents": { - "attendeeId-2": { - "rev": 0, - "timestamp": 0, - "value": { "name": "newId", "args": [42] }, - "ignoreUnmonitored": true, + "n:name:testNotificationWorkspace": { + "testEvents": { + [attendeeId2]: { + "rev": 0, + "timestamp": 0, + "value": { "name": "newId", "args": [42] }, + "ignoreUnmonitored": true, + }, }, }, }, @@ -163,30 +172,32 @@ describe("Presence", () => { clock.tick(10); runtime.signalsExpected.push([ - "Pres:DatastoreUpdate", { - "sendTimestamp": 1020, - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { "rev": 0, "timestamp": 1000, "value": "attendeeId-2" }, + type: "Pres:DatastoreUpdate", + content: { + "sendTimestamp": 1020, + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { "rev": 0, "timestamp": 1000, "value": attendeeId2 }, + }, }, - }, - "n:name:testNotificationWorkspace": { - "testEvents": { - "attendeeId-2": { - "rev": 0, - "timestamp": 0, - "value": { "name": "newId", "args": [42] }, - "ignoreUnmonitored": true, + "n:name:testNotificationWorkspace": { + "testEvents": { + [attendeeId2]: { + "rev": 0, + "timestamp": 0, + "value": { "name": "newId", "args": [42] }, + "ignoreUnmonitored": true, + }, }, }, }, }, + // Targeting self for simplicity + targetClientId: "client2", }, - // Targeting self for simplicity - "client2", ]); // Act & Verify @@ -204,7 +215,7 @@ describe("Presence", () => { }; function originalEventHandler(attendee: Attendee, id: number): void { - assert.equal(attendee.attendeeId, "attendeeId-3"); + assert.equal(attendee.attendeeId, attendeeId3); assert.equal(id, 42); eventHandlerCalls.original.push({ attendee, id }); } @@ -239,7 +250,7 @@ describe("Presence", () => { // Processing this signal should trigger the testEvents.newId event listeners presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -248,12 +259,12 @@ describe("Presence", () => { "data": { "system:presence": { "clientToSessionId": { - "client3": { "rev": 0, "timestamp": 1000, "value": "attendeeId-3" }, + [connectionId3]: { "rev": 0, "timestamp": 1000, "value": attendeeId3 }, }, }, "n:name:testNotificationWorkspace": { "testEvents": { - "attendeeId-3": { + [attendeeId3]: { "rev": 0, "timestamp": 0, "value": { "name": "newId", "args": [42] }, @@ -309,7 +320,7 @@ describe("Presence", () => { testEvents.events.on("unattendedNotification", (name, sender, ...content) => { assert.equal(name, "oldId"); - assert.equal(sender.attendeeId, "attendeeId-3"); + assert.equal(sender.attendeeId, attendeeId3); assert.deepEqual(content, [41]); assert(!unattendedEventCalled); unattendedEventCalled = true; @@ -317,7 +328,7 @@ describe("Presence", () => { // Processing this signal should trigger the testEvents.newId event listeners presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -326,12 +337,12 @@ describe("Presence", () => { "data": { "system:presence": { "clientToSessionId": { - "client3": { "rev": 0, "timestamp": 1000, "value": "attendeeId-3" }, + [connectionId3]: { "rev": 0, "timestamp": 1000, "value": attendeeId3 }, }, }, "n:name:testNotificationWorkspace": { "testEvents": { - "attendeeId-3": { + [attendeeId3]: { "rev": 0, "timestamp": 0, "value": { "name": "oldId", "args": [41] }, @@ -373,7 +384,7 @@ describe("Presence", () => { testEvents.events.on("unattendedNotification", (name, sender, ...content) => { assert.equal(name, "newId"); - assert.equal(sender.attendeeId, "attendeeId-3"); + assert.equal(sender.attendeeId, attendeeId3); assert.deepEqual(content, [43]); assert(!unattendedEventCalled); unattendedEventCalled = true; @@ -383,7 +394,7 @@ describe("Presence", () => { // Processing this signal should trigger the testEvents.newId event listeners presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -392,12 +403,12 @@ describe("Presence", () => { "data": { "system:presence": { "clientToSessionId": { - "client3": { "rev": 0, "timestamp": 1000, "value": "attendeeId-3" }, + [connectionId3]: { "rev": 0, "timestamp": 1000, "value": attendeeId3 }, }, }, "n:name:testNotificationWorkspace": { "testEvents": { - "attendeeId-3": { + [attendeeId3]: { "rev": 0, "timestamp": 0, "value": { "name": "newId", "args": [43] }, @@ -419,7 +430,7 @@ describe("Presence", () => { let originalEventHandlerCalled = false; function originalEventHandler(attendee: Attendee, id: number): void { - assert.equal(attendee.attendeeId, "attendeeId-3"); + assert.equal(attendee.attendeeId, attendeeId3); assert.equal(id, 44); assert.equal(originalEventHandlerCalled, false); originalEventHandlerCalled = true; @@ -455,7 +466,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -464,12 +475,12 @@ describe("Presence", () => { "data": { "system:presence": { "clientToSessionId": { - "client3": { "rev": 0, "timestamp": 1000, "value": "attendeeId-3" }, + [connectionId3]: { "rev": 0, "timestamp": 1000, "value": attendeeId3 }, }, }, "n:name:testNotificationWorkspace": { "testEvents": { - "attendeeId-3": { + [attendeeId3]: { "rev": 0, "timestamp": 0, "value": { "name": "newId", "args": [44] }, diff --git a/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts b/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts index 10f2b2d9f9f0..7c1d0d1cfa9e 100644 --- a/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts +++ b/packages/framework/presence/src/test/presenceDatastoreManager.spec.ts @@ -9,10 +9,29 @@ import { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal import type { SinonFakeTimers } from "sinon"; import { useFakeTimers, spy } from "sinon"; +import type { AttendeeId } from "../presence.js"; import { createPresenceManager } from "../presenceManager.js"; +import type { SystemWorkspaceDatastore } from "../systemWorkspace.js"; import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; -import { assertFinalExpectations, prepareConnectedPresence } from "./testUtils.js"; +import { + assertFinalExpectations, + connectionId2, + createSpecificAttendeeId, + prepareConnectedPresence, + attendeeId1, + attendeeId2, +} from "./testUtils.js"; + +const attendee4SystemWorkspaceDatastore = { + "clientToSessionId": { + ["client4" as AttendeeId]: { + "rev": 0, + "timestamp": 700, + "value": createSpecificAttendeeId("attendeeId-4"), + }, + }, +} as const satisfies SystemWorkspaceDatastore; describe("Presence", () => { describe("protocol handling", () => { @@ -76,34 +95,38 @@ describe("Presence", () => { }), }); runtime.signalsExpected.push([ - "Pres:DatastoreUpdate", { - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": initialTime, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + [connectionId2]: { + "rev": 0, + "timestamp": initialTime, + "value": attendeeId2, + }, }, }, }, + "isComplete": true, + "sendTimestamp": clock.now, }, - "isComplete": true, - "sendTimestamp": clock.now, }, ]); // Act presence.processSignal( - "", + [], { type: "Pres:ClientJoin", content: { sendTimestamp: clock.now - 50, avgLatency: 50, - data: {}, + data: { + "system:presence": attendee4SystemWorkspaceDatastore, + }, updateProviders: ["client2"], }, clientId: "client4", @@ -119,13 +142,15 @@ describe("Presence", () => { // #region Part 1 (no response) // Act presence.processSignal( - "", + [], { type: "Pres:ClientJoin", content: { sendTimestamp: clock.now - 20, avgLatency: 0, - data: {}, + data: { + "system:presence": attendee4SystemWorkspaceDatastore, + }, updateProviders: ["client0", "client1"], }, clientId: "client4", @@ -146,22 +171,25 @@ describe("Presence", () => { }), }); runtime.signalsExpected.push([ - "Pres:DatastoreUpdate", { - "avgLatency": 10, - "data": { - "system:presence": { - "clientToSessionId": { - "client2": { - "rev": 0, - "timestamp": initialTime, - "value": "attendeeId-2", + type: "Pres:DatastoreUpdate", + content: { + "avgLatency": 10, + "data": { + "system:presence": { + "clientToSessionId": { + ...attendee4SystemWorkspaceDatastore.clientToSessionId, + [connectionId2]: { + "rev": 0, + "timestamp": initialTime, + "value": attendeeId2, + }, }, }, }, + "isComplete": true, + "sendTimestamp": clock.now + 180, }, - "isComplete": true, - "sendTimestamp": clock.now + 180, }, ]); @@ -182,14 +210,14 @@ describe("Presence", () => { "client1": { "rev": 0, "timestamp": 0, - "value": "attendeeId-1", + "value": attendeeId1, }, }, }; const statesWorkspaceUpdate = { "latest": { - "attendeeId-1": { + [attendeeId1]: { "rev": 1, "timestamp": 0, "value": {}, @@ -199,17 +227,23 @@ describe("Presence", () => { const notificationsWorkspaceUpdate = { "testEvents": { - "attendeeId-1": { + [attendeeId1]: { "rev": 0, "timestamp": 0, "value": {}, "ignoreUnmonitored": true, }, }, - }; + } as const; beforeEach(() => { - presence = prepareConnectedPresence(runtime, "attendeeId-2", "client2", clock, logger); + presence = prepareConnectedPresence( + runtime, + attendeeId2, + connectionId2, + clock, + logger, + ); // Pass a little time (to mimic reality) clock.tick(10); @@ -222,7 +256,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -250,7 +284,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -281,7 +315,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -291,7 +325,7 @@ describe("Presence", () => { "system:presence": systemWorkspaceUpdate, "u:name:testUnknownWorkspace": { "latest": { - "attendeeId-1": { + [attendeeId1]: { "rev": 1, "timestamp": 0, "value": { x: 1, y: 1, z: 1 }, @@ -319,7 +353,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -347,7 +381,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -375,7 +409,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -403,7 +437,7 @@ describe("Presence", () => { // Act presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { @@ -419,7 +453,7 @@ describe("Presence", () => { false, ); presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { diff --git a/packages/framework/presence/src/test/presenceManager.spec.ts b/packages/framework/presence/src/test/presenceManager.spec.ts index 8889b1cdf1d0..9dbbd945dd83 100644 --- a/packages/framework/presence/src/test/presenceManager.spec.ts +++ b/packages/framework/presence/src/test/presenceManager.spec.ts @@ -16,10 +16,13 @@ import { createPresenceManager } from "../presenceManager.js"; import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; import { assertFinalExpectations, + createSpecificAttendeeId, generateBasicClientJoin, prepareConnectedPresence, } from "./testUtils.js"; +const collateralSessionId = createSpecificAttendeeId("collateral-id"); + describe("Presence", () => { describe("PresenceManager", () => { let runtime: MockEphemeralRuntime; @@ -113,7 +116,7 @@ describe("Presence", () => { ); for (const signal of signals) { - presence.processSignal("", signal, false); + presence.processSignal([], signal, false); } cleanUpListener(); @@ -266,7 +269,7 @@ describe("Presence", () => { [collateralAttendeeConnectionId]: { rev: 0, timestamp: 0, - value: "collateral-id", + value: collateralSessionId, }, }, }); @@ -296,7 +299,7 @@ describe("Presence", () => { // Rejoin signal for the collateral attendee unknown to audience const rejoinSignal = generateBasicClientJoin(clock.now - 10, { averageLatency: 40, - attendeeId: "collateral-id", + attendeeId: collateralSessionId, clientConnectionId: newAttendeeConnectionId, updateProviders: [initialAttendeeConnectionId], connectionOrder: 1, @@ -304,7 +307,7 @@ describe("Presence", () => { [oldAttendeeConnectionId]: { rev: 0, timestamp: 0, - value: "collateral-id", + value: collateralSessionId, }, }, }); @@ -321,7 +324,7 @@ describe("Presence", () => { [oldAttendeeConnectionId]: { rev: 0, timestamp: 0, - value: "collateral-id", + value: collateralSessionId, }, }, }); @@ -353,7 +356,7 @@ describe("Presence", () => { "Expected no attendees to be announced", ); // Check attendee information remains unchanged - verifyAttendee(rejoinAttendees[0], newAttendeeConnectionId, "collateral-id"); + verifyAttendee(rejoinAttendees[0], newAttendeeConnectionId, collateralSessionId); }); }); @@ -575,7 +578,7 @@ describe("Presence", () => { runtime.connect("client6"); clock.tick(15_000); presence.processSignal( - "", + [], { type: "Pres:DatastoreUpdate", content: { diff --git a/packages/framework/presence/src/test/testUtils.ts b/packages/framework/presence/src/test/testUtils.ts index ec1112bcb838..ab2cbc5271fe 100644 --- a/packages/framework/presence/src/test/testUtils.ts +++ b/packages/framework/presence/src/test/testUtils.ts @@ -9,11 +9,12 @@ import { getUnexpectedLogErrorException } from "@fluidframework/test-utils/inter import type { SinonFakeTimers } from "sinon"; import { createPresenceManager } from "../presenceManager.js"; +import type { InboundClientJoinMessage, OutboundClientJoinMessage } from "../protocol.js"; +import type { SystemWorkspaceDatastore } from "../systemWorkspace.js"; import type { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; -import type { ClientConnectionId, AttendeeId } from "@fluidframework/presence/alpha"; -import type { IExtensionMessage } from "@fluidframework/presence/internal/container-definitions/internal"; +import type { AttendeeId, ClientConnectionId } from "@fluidframework/presence/alpha"; /** * Use to compile-time assert types of two variables are identical. @@ -32,15 +33,51 @@ export function createInstanceOf(): T { return undefined as T; } +type SpecificAttendeeId = string extends T + ? never + : Exclude; + +/** + * Forms {@link AttendeeId} for a specific attendee + */ +export function createSpecificAttendeeId( + id: T, +): SpecificAttendeeId { + return id as SpecificAttendeeId; +} + +/** + * Mock {@link AttendeeId}. + */ +export const attendeeId1 = createSpecificAttendeeId("attendeeId-1"); +/** + * Mock {@link AttendeeId}. + */ +export const attendeeId2 = createSpecificAttendeeId("attendeeId-2"); +/** + * Mock {@link ClientConnectionId}. + * + * @remarks + * This is an {@link AttendeeId} as a workaround to TypeScript expectation + * that specific properties overriding an indexed property still conform + * to the index signature. This makes cases where it is used as + * `clientConnectionId` key in {@link SystemWorkspaceDatastore} also + * satisfy {@link GeneralDatastoreMessageContent}'s `AttendeeId` key. + * + * The only known alternative is to use + * `satisfies SystemWorkspaceDatastore as SystemWorkspaceDatastore` + * wherever "system:presence" is defined. + */ +export const connectionId2 = createSpecificAttendeeId("client2"); + /** - * Generates expected join signal for a client that was initialized while connected. + * Generates expected inbound join signal for a client that was initialized while connected. */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type export function generateBasicClientJoin( fixedTime: number, { - attendeeId = "attendeeId-2", - clientConnectionId = "client2", + attendeeId = attendeeId2, + clientConnectionId = connectionId2, updateProviders = ["client0", "client1", "client3"], connectionOrder = 0, averageLatency = 0, @@ -51,12 +88,9 @@ export function generateBasicClientJoin( updateProviders?: string[]; connectionOrder?: number; averageLatency?: number; - priorClientToSessionId?: Record< - ClientConnectionId, - { rev: number; timestamp: number; value: string } - >; + priorClientToSessionId?: SystemWorkspaceDatastore["clientToSessionId"]; }, -) { +): InboundClientJoinMessage { return { type: "Pres:ClientJoin", content: { @@ -68,7 +102,7 @@ export function generateBasicClientJoin( [clientConnectionId]: { "rev": connectionOrder, "timestamp": fixedTime, - "value": attendeeId, + "value": attendeeId as AttendeeId, }, }, }, @@ -77,7 +111,7 @@ export function generateBasicClientJoin( updateProviders, }, clientId: clientConnectionId, - } satisfies IExtensionMessage<"Pres:ClientJoin">; + }; } /** @@ -112,12 +146,14 @@ export function prepareConnectedPresence( quorumClientIds.length = 3; } - const expectedClientJoin = generateBasicClientJoin(clock.now, { + const expectedClientJoin: OutboundClientJoinMessage & + Partial> = generateBasicClientJoin(clock.now, { attendeeId, clientConnectionId, updateProviders: quorumClientIds, }); - runtime.signalsExpected.push([expectedClientJoin.type, expectedClientJoin.content]); + delete expectedClientJoin.clientId; + runtime.signalsExpected.push([expectedClientJoin]); const presence = createPresenceManager(runtime, attendeeId as AttendeeId); @@ -133,7 +169,7 @@ export function prepareConnectedPresence( clock.tick(10); // Return the join signal - presence.processSignal("", { ...expectedClientJoin, clientId: clientConnectionId }, true); + presence.processSignal([], { ...expectedClientJoin, clientId: clientConnectionId }, true); return presence; } diff --git a/packages/loader/container-loader/src/container.ts b/packages/loader/container-loader/src/container.ts index 14f7c264751e..6da51157ca4f 100644 --- a/packages/loader/container-loader/src/container.ts +++ b/packages/loader/container-loader/src/container.ts @@ -15,7 +15,7 @@ import { IAudience, ICriticalContainerError, } from "@fluidframework/container-definitions"; -import { +import type { ContainerWarning, IBatchMessage, ICodeDetailsLoader, @@ -30,11 +30,11 @@ import { IProvideFluidCodeDetailsComparer, IProvideRuntimeFactory, IRuntime, - isFluidCodeDetails, ReadOnlyInfo, - type ILoader, - type ILoaderOptions, + ILoader, + ILoaderOptions, } from "@fluidframework/container-definitions/internal"; +import { isFluidCodeDetails } from "@fluidframework/container-definitions/internal"; import { FluidObject, IEvent, diff --git a/packages/runtime/container-runtime-definitions/src/containerExtension.ts b/packages/runtime/container-runtime-definitions/src/containerExtension.ts new file mode 100644 index 000000000000..28f40e031937 --- /dev/null +++ b/packages/runtime/container-runtime-definitions/src/containerExtension.ts @@ -0,0 +1,301 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { IAudience } from "@fluidframework/container-definitions/internal"; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- BrandedType is a class declaration only +import type { + BrandedType, + InternalUtilityTypes, + ITelemetryBaseLogger, + JsonDeserialized, + JsonSerializable, + Listenable, + TypedMessage, +} from "@fluidframework/core-interfaces/internal"; +import type { IQuorumClients } from "@fluidframework/driver-definitions/internal"; + +/** + * While connected, the id of a client within a session. + * + * @internal + */ +export type ClientConnectionId = string; + +/** + * Common structure between incoming and outgoing extension signals. + * + * @remarks + * Do not use directly, use {@link OutboundExtensionMessage} or {@link InboundExtensionMessage} instead. + * + * @sealed + * @internal + */ +export type ExtensionMessage< + TMessage extends TypedMessage = { + type: string; + content: JsonSerializable | JsonDeserialized; + }, +> = // `TMessage extends TypedMessage` encourages processing union elements individually + TMessage extends TypedMessage + ? InternalUtilityTypes.FlattenIntersection< + TMessage & { + /** + * Client ID of the singular client the message is being (or has been) sent to. + * May only be specified when IConnect.supportedFeatures['submit_signals_v2'] is true, will throw otherwise. + */ + targetClientId?: ClientConnectionId; + } + > + : never; + +/** + * Outgoing extension signals. + * + * @sealed + * @internal + */ +export type OutboundExtensionMessage = + ExtensionMessage<{ type: TMessage["type"]; content: JsonSerializable }>; + +/** + * Brand for value that has not been verified. + * + * Usage: + * + * - Cast to with `as unknown as UnverifiedBrand` when value of or containing expected type `T` is yet unknown. + * + * - Cast from with `as unknown` when "instance" will be parsed to `T`. + * + * @sealed + * @internal + */ +export declare class UnverifiedBrand extends BrandedType { + private readonly UnverifiedValue: T; + private constructor(); +} + +/** + * Unverified incoming extension signals. + * + * @sealed + * @internal + */ +export type RawInboundExtensionMessage = + // `TMessage extends TypedMessage` encourages processing union elements individually + TMessage extends TypedMessage + ? InternalUtilityTypes.FlattenIntersection< + ExtensionMessage<{ + type: string; + content: JsonDeserialized; + }> & { + /** + * The client ID that submitted the message. + * For server generated messages the clientId will be null. + */ + // eslint-disable-next-line @rushstack/no-new-null + clientId: ClientConnectionId | null; + } + > & + UnverifiedBrand + : never; + +/** + * Verified incoming extension signals. + * + * @sealed + * @internal + */ +export type VerifiedInboundExtensionMessage = + // `TMessage extends TypedMessage` encourages processing union elements individually + TMessage extends TypedMessage + ? InternalUtilityTypes.FlattenIntersection< + ExtensionMessage<{ + type: TMessage["type"]; + content: JsonDeserialized; + }> & { + /** + * The client ID that submitted the message. + * For server generated messages the clientId will be null. + */ + // eslint-disable-next-line @rushstack/no-new-null + clientId: ClientConnectionId | null; + } + > + : never; + +/** + * Incoming extension signal that may be of the known type or has not yet been validated. + * + * @sealed + * @internal + */ +export type InboundExtensionMessage = + | RawInboundExtensionMessage + | VerifiedInboundExtensionMessage; + +/** + * @internal + */ +export interface ExtensionRuntimeProperties { + SignalMessages: TypedMessage; +} + +/** + * Defines requirements for a component to register with container as an extension. + * + * @internal + */ +export interface ContainerExtension< + TRuntimeProperties extends ExtensionRuntimeProperties, + TUseContext extends unknown[] = [], +> { + /** + * Notifies the extension of a new use context. + * + * @param useContext - Context new reference to extension is acquired within. + * + * @remarks + * This is called when a secondary reference to the extension is acquired. + * useContext is the array of arguments that would otherwise be passed to + * the factory during first acquisition request. + */ + onNewUse(...useContext: TUseContext): void; + + /** + * Callback for signal sent by this extension. + * + * @param addressChain - Address chain of the signal + * @param signalMessage - Signal unverified content and metadata + * @param local - True if signal was sent by this client + * + */ + processSignal?: ( + addressChain: string[], + signalMessage: InboundExtensionMessage, + local: boolean, + ) => void; +} + +/** + * Events emitted by the {@link ExtensionHost}. + * + * @internal + */ +export interface ExtensionHostEvents { + "disconnected": () => void; + "connected": (clientId: ClientConnectionId) => void; +} + +/** + * Defines the runtime aspects an extension may access. + * + * @remarks + * In most cases this is a logical subset of {@link @fluidframework/container-runtime-definitions#IContainerRuntime}. + * + * @sealed + * @internal + */ +export interface ExtensionHost { + readonly isConnected: () => boolean; + readonly getClientId: () => ClientConnectionId | undefined; + + readonly events: Listenable; + + readonly logger: ITelemetryBaseLogger; + + /** + * Submits a signal to be sent to other clients. + * @param addressChain - Custom address sequence for the signal. + * @param message - Custom message content of the signal. + * + * Upon receipt of signal, {@link ContainerExtension.processSignal} will be called with the same + * address and message (less any non-{@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify|JSON.stringify}-able data). + */ + submitAddressedSignal: ( + addressChain: string[], + message: OutboundExtensionMessage, + ) => void; + + /** + * The collection of write clients which were connected as of the current sequence number. + * Also contains a map of key-value pairs that must be agreed upon by all clients before being accepted. + */ + getQuorum: () => IQuorumClients; + + getAudience: () => IAudience; +} + +/** + * Factory method to create an extension instance. + * + * Any such method provided to {@link ContainerExtensionStore.acquireExtension} + * must use the same value for a given {@link ContainerExtensionId} so that an + * `instanceof` check may be performed at runtime. + * + * @typeParam T - Type of extension to create + * @typeParam TRuntimeProperties - Extension runtime properties + * @typeParam TUseContext - Array of custom use context passed to factory or onNewUse + * + * @param host - Host runtime for extension to work against + * @param useContext - Custom use context for extension. + * @returns Record providing: + * `interface` instance (type `T`) that is provided to caller of + * {@link ContainerExtensionStore.acquireExtension} and + * `extension` store/runtime uses to interact with extension. + * + * @internal + */ +export type ContainerExtensionFactory< + T, + TRuntimeProperties extends ExtensionRuntimeProperties, + TUseContext extends unknown[] = [], +> = new ( + host: ExtensionHost, + ...useContext: TUseContext +) => { + readonly interface: T; + readonly extension: ContainerExtension; +}; + +/** + * Unique identifier for extension + * + * @remarks + * A string known to all clients working with a certain ContainerExtension and unique + * among ContainerExtensions. Not `/` may be used in the string. Recommend using + * concatenation of: type of unique identifier, `:` (required), and unique identifier. + * + * @example Examples + * ```typescript + * "guid:g0fl001d-1415-5000-c00l-g0fa54g0b1g1" + * "name:@foo-scope_bar:v1" + * ``` + * + * @internal + */ +export type ContainerExtensionId = `${string}:${string}`; + +/** + * @sealed + * @internal + */ +export interface ContainerExtensionStore { + /** + * Acquires an extension from store or adds new one. + * + * @param id - Identifier for the requested extension + * @param factory - Factory to create the extension if not found + * @returns The extension + */ + acquireExtension< + T, + TRuntimeProperties extends ExtensionRuntimeProperties, + TUseContext extends unknown[] = [], + >( + id: ContainerExtensionId, + factory: ContainerExtensionFactory, + ...context: TUseContext + ): T; +} diff --git a/packages/runtime/container-runtime-definitions/src/containerRuntime.ts b/packages/runtime/container-runtime-definitions/src/containerRuntime.ts index e10d65b9afca..e778d48661bd 100644 --- a/packages/runtime/container-runtime-definitions/src/containerRuntime.ts +++ b/packages/runtime/container-runtime-definitions/src/containerRuntime.ts @@ -26,6 +26,8 @@ import type { IProvideFluidDataStoreRegistry, } from "@fluidframework/runtime-definitions/internal"; +import type { ContainerExtensionStore } from "./containerExtension.js"; + /** * @deprecated Will be removed in future major release. Migrate all usage of IFluidRouter to the "entryPoint" pattern. Refer to Removing-IFluidRouter.md * @legacy @@ -198,3 +200,12 @@ export interface IContainerRuntime */ getAbsoluteUrl(relativeUrl: string): Promise; } + +/** + * Represents the internal version of the runtime of the container. + * + * @internal + */ +export interface IContainerRuntimeInternal + extends IContainerRuntime, + ContainerExtensionStore {} diff --git a/packages/runtime/container-runtime-definitions/src/index.ts b/packages/runtime/container-runtime-definitions/src/index.ts index 10d6eaaa23b9..bf0dc6d1eaae 100644 --- a/packages/runtime/container-runtime-definitions/src/index.ts +++ b/packages/runtime/container-runtime-definitions/src/index.ts @@ -3,10 +3,27 @@ * Licensed under the MIT License. */ +export type { + ClientConnectionId, + ContainerExtensionFactory, + ContainerExtensionId, + ContainerExtensionStore, + ContainerExtension, + ExtensionHost, + ExtensionHostEvents, + ExtensionMessage, + ExtensionRuntimeProperties, + InboundExtensionMessage, + OutboundExtensionMessage, + RawInboundExtensionMessage, + UnverifiedBrand, + VerifiedInboundExtensionMessage, +} from "./containerExtension.js"; export type { IContainerRuntime, IContainerRuntimeBaseWithCombinedEvents, IContainerRuntimeEvents, + IContainerRuntimeInternal, IContainerRuntimeWithResolveHandle_Deprecated, SummarizerStopReason, ISummarizeEventProps, diff --git a/packages/runtime/container-runtime/src/containerRuntime.ts b/packages/runtime/container-runtime/src/containerRuntime.ts index 05b880f15c21..8e05a015f658 100644 --- a/packages/runtime/container-runtime/src/containerRuntime.ts +++ b/packages/runtime/container-runtime/src/containerRuntime.ts @@ -7,7 +7,7 @@ import type { ILayerCompatDetails, IProvideLayerCompatDetails, } from "@fluid-internal/client-utils"; -import { Trace, TypedEventEmitter } from "@fluid-internal/client-utils"; +import { createEmitter, Trace, TypedEventEmitter } from "@fluid-internal/client-utils"; import type { IAudience, ISelf, @@ -25,8 +25,15 @@ import type { } from "@fluidframework/container-definitions/internal"; import { isIDeltaManagerFull } from "@fluidframework/container-definitions/internal"; import type { + ContainerExtensionFactory, + ContainerExtensionId, + ExtensionHost, + ExtensionHostEvents, + ExtensionRuntimeProperties, IContainerRuntime, IContainerRuntimeEvents, + IContainerRuntimeInternal, + OutboundExtensionMessage, } from "@fluidframework/container-runtime-definitions/internal"; import type { FluidObject, @@ -34,6 +41,7 @@ import type { IRequest, IResponse, ITelemetryBaseLogger, + Listenable, } from "@fluidframework/core-interfaces"; import type { IErrorBase, @@ -41,10 +49,13 @@ import type { IFluidHandleInternal, IProvideFluidHandleContext, ISignalEnvelope, + JsonDeserialized, + TypedMessage, } from "@fluidframework/core-interfaces/internal"; import { assert, Deferred, + Lazy, LazyPromise, PromiseCache, delay, @@ -284,6 +295,16 @@ import { } from "./summary/index.js"; import { Throttler, formExponentialFn } from "./throttler.js"; +/** + * A {@link ContainerExtension}'s factory function as stored in extension map. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- `any` required to allow typed factory to be assignable per ContainerExtension.processSignal +type ExtensionEntry = ContainerExtensionFactory extends new ( + ...args: any[] +) => infer T + ? T + : never; + /** * Creates an error object to be thrown / passed to Container's close fn in case of an unknown message type. * The parameters are typed to support compile-time enforcement of handling all known types/behaviors @@ -674,6 +695,8 @@ export let getSingleUseLegacyLogCallback = (logger: ITelemetryLoggerExt, type: s }; }; +type UnsequencedSignalEnvelope = Omit; + /** * This object holds the parameters necessary for the {@link loadContainerRuntime} function. * @legacy @@ -757,7 +780,7 @@ const defaultMaxConsecutiveReconnects = 7; export class ContainerRuntime extends TypedEventEmitter implements - IContainerRuntime, + IContainerRuntimeInternal, // eslint-disable-next-line import/no-deprecated IContainerRuntimeBaseExperimental, IRuntime, @@ -1148,10 +1171,10 @@ export class ContainerRuntime summaryOp: ISummaryContent, referenceSequenceNumber?: number, ) => number; - /** - * Do not call directly - use submitAddressesSignal - */ - private readonly submitSignalFn: (content: ISignalEnvelope, targetClientId?: string) => void; + private readonly submitSignalFn: ( + content: UnsequencedSignalEnvelope, + targetClientId?: string, + ) => void; public readonly disposeFn: (error?: ICriticalContainerError) => void; public readonly closeFn: (error?: ICriticalContainerError) => void; @@ -1405,6 +1428,8 @@ export class ContainerRuntime */ private readonly skipSafetyFlushDuringProcessStack: boolean; + private readonly extensions = new Map(); + /***/ protected constructor( context: IContainerContext, @@ -1498,7 +1523,35 @@ export class ContainerRuntime this.submitSummaryFn = submitSummaryFn ?? ((summaryOp, refseq) => submitFn(MessageType.Summarize, summaryOp, false)); - this.submitSignalFn = submitSignalFn; + + const sequenceAndSubmitSignal = ( + envelope: UnsequencedSignalEnvelope, + targetClientId?: string, + ): void => { + if (targetClientId === undefined) { + this.signalTelemetryManager.applyTrackingToBroadcastSignalEnvelope(envelope); + } + submitSignalFn(envelope, targetClientId); + }; + this.submitSignalFn = (envelope: UnsequencedSignalEnvelope, targetClientId?: string) => { + if (envelope.address?.startsWith("/")) { + throw new Error("General path based addressing is not implemented"); + } + sequenceAndSubmitSignal(envelope, targetClientId); + }; + this.submitExtensionSignal = ( + id: string, + addressChain: string[], + message: OutboundExtensionMessage, + ): void => { + this.verifyNotClosed(); + const envelope = createNewSignalEnvelope( + `/ext/${id}/${addressChain.join("/")}`, + message.type, + message.content, + ); + sequenceAndSubmitSignal(envelope, message.targetClientId); + }; // TODO: After IContainerContext.options is removed, we'll just create a new blank object {} here. // Values are generally expected to be set from the runtime side. @@ -1757,9 +1810,6 @@ export class ContainerRuntime // verifyNotClosed is called in FluidDataStoreContext, which is *the* expected caller. const envelope1 = content as IEnvelope; const envelope2 = createNewSignalEnvelope(envelope1.address, type, envelope1.contents); - if (targetClientId === undefined) { - this.signalTelemetryManager.applyTrackingToBroadcastSignalEnvelope(envelope2); - } this.submitSignalFn(envelope2, targetClientId); }; @@ -3156,9 +3206,15 @@ export class ContainerRuntime } } - public processSignal(message: ISignalMessage, local: boolean): void { - const envelope = message.content as ISignalEnvelope; - const transformed: IInboundSignalMessage = { + public processSignal( + message: ISignalMessage<{ + type: string; + content: ISignalEnvelope<{ type: string; content: JsonDeserialized }>; + }>, + local: boolean, + ): void { + const envelope = message.content; + const transformed = { clientId: message.clientId, content: envelope.contents.content, type: envelope.contents.type, @@ -3174,22 +3230,53 @@ export class ContainerRuntime ); } - if (envelope.address === undefined) { + const fullAddress = envelope.address; + if (fullAddress === undefined) { // No address indicates a container signal message. this.emit("signal", transformed, local); return; } - // Due to a mismatch between different layers in terms of - // what is the interface of passing signals, we need to adjust - // the signal envelope before sending it to the datastores to be processed - const envelope2: IEnvelope = { - address: envelope.address, - contents: transformed.content, - }; - transformed.content = envelope2; + this.routeNonContainerSignal(fullAddress, transformed, local); + } - this.channelCollection.processSignal(transformed, local); + private routeNonContainerSignal( + address: string, + signalMessage: IInboundSignalMessage<{ type: string; content: JsonDeserialized }>, + local: boolean, + ): void { + // channelCollection signals are identified by no starting `/` in address. + if (!address.startsWith("/")) { + // Due to a mismatch between different layers in terms of + // what is the interface of passing signals, we need to adjust + // the signal envelope before sending it to the datastores to be processed + const envelope = { + address, + contents: signalMessage.content, + }; + signalMessage.content = envelope; + + this.channelCollection.processSignal(signalMessage, local); + return; + } + + const addresses = address.split("/"); + if (addresses.length > 2 && addresses[1] === "ext") { + const id = addresses[2] as ContainerExtensionId; + const entry = this.extensions.get(id); + if (entry !== undefined) { + entry.extension.processSignal?.(addresses.slice(3), signalMessage, local); + return; + } + } + + assert(!local, "No recipient found for local signal"); + this.mc.logger.sendTelemetryEvent({ + eventName: "SignalAddressNotFound", + ...tagCodeArtifacts({ + address, + }), + }); } /** @@ -3484,9 +3571,6 @@ export class ContainerRuntime public submitSignal(type: string, content: unknown, targetClientId?: string): void { this.verifyNotClosed(); const envelope = createNewSignalEnvelope(undefined /* address */, type, content); - if (targetClientId === undefined) { - this.signalTelemetryManager.applyTrackingToBroadcastSignalEnvelope(envelope); - } this.submitSignalFn(envelope, targetClientId); } @@ -4876,6 +4960,56 @@ export class ContainerRuntime } } + // While internal, ContainerRuntime has not been converted to use the new events support. + // Recreate the required events (new pattern) with injected, wrapper new emitter. + // It is lazily create to avoid listeners (old events) that ultimately go nowhere. + private readonly lazyEventsForExtensions = new Lazy>(() => { + const eventEmitter = createEmitter(); + this.on("connected", (clientId) => eventEmitter.emit("connected", clientId)); + this.on("disconnected", () => eventEmitter.emit("disconnected")); + return eventEmitter; + }); + + private readonly submitExtensionSignal: ( + id: string, + addressChain: string[], + message: OutboundExtensionMessage, + ) => void; + + public acquireExtension< + T, + TRuntimeProperties extends ExtensionRuntimeProperties, + TUseContext extends unknown[], + >( + id: ContainerExtensionId, + factory: ContainerExtensionFactory, + ...useContext: TUseContext + ): T { + let entry = this.extensions.get(id); + if (entry === undefined) { + const runtime = { + isConnected: () => this.connected, + getClientId: () => this.clientId, + events: this.lazyEventsForExtensions.value, + logger: this.baseLogger, + submitAddressedSignal: ( + addressChain: string[], + message: OutboundExtensionMessage, + ) => { + this.submitExtensionSignal(id, addressChain, message); + }, + getQuorum: this.getQuorum.bind(this), + getAudience: this.getAudience.bind(this), + } satisfies ExtensionHost; + entry = new factory(runtime, ...useContext); + this.extensions.set(id, entry); + } else { + assert(entry instanceof factory, "Extension entry is not of the expected type"); + entry.extension.onNewUse(...useContext); + } + return entry.interface as T; + } + private get groupedBatchingEnabled(): boolean { return this.sessionSchema.opGroupingEnabled === true; } @@ -4885,8 +5019,8 @@ export function createNewSignalEnvelope( address: string | undefined, type: string, content: unknown, -): Omit { - const newEnvelope: Omit = { +): UnsequencedSignalEnvelope { + const newEnvelope: UnsequencedSignalEnvelope = { address, contents: { type, content }, }; diff --git a/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts b/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts index d7a75f8fb0b7..dad9be43d70d 100644 --- a/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts +++ b/packages/runtime/container-runtime/src/test/containerRuntime.spec.ts @@ -27,6 +27,7 @@ import { ISignalEnvelope, type IErrorBase, type ITelemetryBaseLogger, + type JsonDeserialized, } from "@fluidframework/core-interfaces/internal"; import { ISummaryTree } from "@fluidframework/driver-definitions"; import { @@ -135,12 +136,14 @@ const changeConnectionState = ( }; interface ISignalEnvelopeWithClientIds { - envelope: ISignalEnvelope; + envelope: ISignalEnvelope<{ type: string; content: JsonDeserialized }>; clientId: string; targetClientId?: string; } -function isSignalEnvelope(obj: unknown): obj is ISignalEnvelope { +function isSignalEnvelope( + obj: unknown, +): obj is ISignalEnvelope<{ type: string; content: JsonDeserialized }> { return ( typeof obj === "object" && obj !== null && @@ -1023,7 +1026,7 @@ describe("Runtime", () => { function patchRuntime( pendingStateManager: PendingStateManager, _maxReconnects: number | undefined = undefined, - ) { + ): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment -- Modifying private properties const runtime = containerRuntime as any; // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access @@ -1032,7 +1035,6 @@ describe("Runtime", () => { runtime.channelCollection = getMockChannelCollection(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment runtime.maxConsecutiveReconnects = _maxReconnects ?? runtime.maxConsecutiveReconnects; - return runtime as ContainerRuntime; } /** @@ -1599,7 +1601,7 @@ describe("Runtime", () => { explicitSchemaControl: false, createBlobPayloadPending: undefined, } as const satisfies ContainerRuntimeOptionsInternal; - const mergedRuntimeOptions = { ...defaultRuntimeOptions, ...runtimeOptions }; + const mergedRuntimeOptions = { ...defaultRuntimeOptions, ...runtimeOptions } as const; it("Container load stats", async () => { await ContainerRuntime.loadRuntime({ @@ -1967,7 +1969,10 @@ describe("Runtime", () => { * This always returns the same snapshot. Basically, when container runtime receives an ack for the * deleted snapshot and tries to fetch the latest snapshot, return the latest snapshot. */ - async getSnapshotTree(version?: IVersion, scenarioName?: string) { + async getSnapshotTree( + version?: IVersion, + scenarioName?: string, + ): Promise { assert.strictEqual( version, latestVersion, @@ -1982,7 +1987,7 @@ describe("Runtime", () => { count: number, scenarioName?: string, fetchSource?: FetchSource, - ) { + ): Promise { return [latestVersion]; } @@ -1990,7 +1995,10 @@ describe("Runtime", () => { * Validates that this is not called by container runtime with the deleted snapshot id even * though it received an ack for it. */ - async uploadSummaryWithContext(summary: ISummaryTree, context: ISummaryContext) { + async uploadSummaryWithContext( + summary: ISummaryTree, + context: ISummaryContext, + ): Promise { assert.notStrictEqual( context.ackHandle, deletedSnapshotId, @@ -2003,7 +2011,7 @@ describe("Runtime", () => { * Called by container runtime to read document attributes. Return the sequence number as 0 which * is lower than the deleted snapshot's reference sequence number. */ - async readBlob(id: string) { + async readBlob(id: string): Promise { assert.strictEqual(id, "attributesBlob", "Not implemented"); const attributes: IDocumentAttributes = { sequenceNumber: 0, @@ -2504,7 +2512,7 @@ describe("Runtime", () => { logger.clear(); }); - function createSnapshot(addMissingDatastore: boolean, setGroupId: boolean = true) { + function createSnapshot(addMissingDatastore: boolean, setGroupId: boolean = true): void { if (addMissingDatastore) { snapshotTree.trees[".channels"].trees.missingDataStore = { blobs: { ".component": "id" }, @@ -2855,7 +2863,7 @@ describe("Runtime", () => { logger.clear(); }); - function sendSignals(count: number) { + function sendSignals(count: number): void { for (let i = 0; i < count; i++) { containerRuntime.submitSignal("TestSignalType", `TestSignalContent ${i + 1}`); assert( @@ -2871,7 +2879,7 @@ describe("Runtime", () => { } } - function processSignals(signals: ISignalEnvelopeWithClientIds[], count: number) { + function processSignals(signals: ISignalEnvelopeWithClientIds[], count: number): void { const signalsToProcess = signals.splice(0, count); for (const signal of signalsToProcess) { if (signal.targetClientId === undefined) { @@ -2909,7 +2917,7 @@ describe("Runtime", () => { } } - function processWithNoTargetSupport(count: number) { + function processWithNoTargetSupport(count: number): void { const signalsToProcess = submittedSignals.splice(0, count); for (const signal of signalsToProcess) { for (const runtime of runtimes.values()) { @@ -2928,15 +2936,15 @@ describe("Runtime", () => { } } - function processSubmittedSignals(count: number) { + function processSubmittedSignals(count: number): void { processSignals(submittedSignals, count); } - function processDroppedSignals(count: number) { + function processDroppedSignals(count: number): void { processSignals(droppedSignals, count); } - function dropSignals(count: number) { + function dropSignals(count: number): void { const signalsToDrop = submittedSignals.splice(0, count); droppedSignals.push(...signalsToDrop); } @@ -3399,7 +3407,7 @@ describe("Runtime", () => { let remoteContainerRuntime: ContainerRuntime; let remoteLogger: MockLogger; - function sendRemoteSignals(count: number) { + function sendRemoteSignals(count: number): void { for (let i = 0; i < count; i++) { remoteContainerRuntime.submitSignal( "TestSignalType", diff --git a/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md b/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md index aaaaf328faa1..d19bb9799bc2 100644 --- a/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md +++ b/packages/runtime/runtime-definitions/api-report/runtime-definitions.legacy.alpha.api.md @@ -263,9 +263,9 @@ export interface IGarbageCollectionDetailsBase { } // @alpha @legacy -export interface IInboundSignalMessage extends ISignalMessage { +export interface IInboundSignalMessage extends ISignalMessage { // (undocumented) - readonly type: string; + readonly type: TMessage["type"]; } // @alpha @legacy diff --git a/packages/runtime/runtime-definitions/src/protocol.ts b/packages/runtime/runtime-definitions/src/protocol.ts index 762aaf02c2a5..7168bbfc3b22 100644 --- a/packages/runtime/runtime-definitions/src/protocol.ts +++ b/packages/runtime/runtime-definitions/src/protocol.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. */ +import type { TypedMessage } from "@fluidframework/core-interfaces/internal"; import type { ITree, ISignalMessage, @@ -32,8 +33,9 @@ export interface IEnvelope { * @legacy * @alpha */ -export interface IInboundSignalMessage extends ISignalMessage { - readonly type: string; +export interface IInboundSignalMessage + extends ISignalMessage { + readonly type: TMessage["type"]; } /** diff --git a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts index 5cdee9d43b01..f7883c1098d7 100644 --- a/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts +++ b/packages/service-clients/end-to-end-tests/azure-client/src/test/multiprocess/childClient.ts @@ -15,11 +15,10 @@ import { AttachState } from "@fluidframework/container-definitions"; import { ConnectionState } from "@fluidframework/container-loader"; import { ContainerSchema, type IFluidContainer } from "@fluidframework/fluid-static"; import { - getPresenceViaDataObject, + getPresence, + type Attendee, ExperimentalPresenceManager, - type ExperimentalPresenceDO, type Presence, - type Attendee, // eslint-disable-next-line import/no-internal-modules } from "@fluidframework/presence/alpha"; import { InsecureTokenProvider } from "@fluidframework/test-runtime-utils/internal"; @@ -83,7 +82,8 @@ const getOrCreatePresenceContainer = async ( const client = new AzureClient({ connection: connectionProps }); const schema: ContainerSchema = { initialObjects: { - presence: ExperimentalPresenceManager, + // A DataObject is added as otherwise fluid-static complains "Container cannot be initialized without any DataTypes" + _unused: ExperimentalPresenceManager, }, }; let services: AzureContainerServices; @@ -107,9 +107,7 @@ const getOrCreatePresenceContainer = async ( "Container is not attached after attach is called", ); - const presence = getPresenceViaDataObject( - container.initialObjects.presence as ExperimentalPresenceDO, - ); + const presence = getPresence(container); return { client, container, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f11b880bf1ec..81412a2dc360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11527,9 +11527,6 @@ importers: '@fluidframework/container-definitions': specifier: workspace:~ version: link:../../common/container-definitions - '@fluidframework/container-loader': - specifier: workspace:~ - version: link:../../loader/container-loader '@fluidframework/container-runtime-definitions': specifier: workspace:~ version: link:../../runtime/container-runtime-definitions