diff --git a/PACKAGES.md b/PACKAGES.md index b2d83ed86478..cb62eb4ea8e5 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -183,7 +183,7 @@ The dependencies between layers are enforced by the layer-check command._ | Packages | Layer Dependencies | | --- | --- | -| - [@fluidframework/azure-client](/packages/service-clients/azure-client)
- [@fluidframework/odsp-client](/packages/service-clients/odsp-client)
- [@fluidframework/tinylicious-client](/packages/service-clients/tinylicious-client)
 
 
 
 
 
 
 
 
  | - [Core-Interfaces](#Core-Interfaces)
- [Driver-Definitions](#Driver-Definitions)
- [Container-Definitions](#Container-Definitions)
- [Core-Utils](#Core-Utils)
- [Telemetry-Utils](#Telemetry-Utils)
- [Driver-Utils](#Driver-Utils)
- [Other-Utils](#Other-Utils)
- [Driver](#Driver)
- [Loader](#Loader)
- [Runtime](#Runtime)
- [Framework](#Framework)
- [Routerlicious-Driver](#Routerlicious-Driver) | +| - [@fluidframework/azure-client](/packages/service-clients/azure-client)
- [@fluidframework/odsp-client](/packages/service-clients/odsp-client)
- [@fluidframework/tinylicious-client](/packages/service-clients/tinylicious-client)
 
 
 
 
 
 
 
 
 
  | - [Core-Interfaces](#Core-Interfaces)
- [Driver-Definitions](#Driver-Definitions)
- [Container-Definitions](#Container-Definitions)
- [Core-Utils](#Core-Utils)
- [Client-Utils](#Client-Utils)
- [Telemetry-Utils](#Telemetry-Utils)
- [Driver-Utils](#Driver-Utils)
- [Other-Utils](#Other-Utils)
- [Driver](#Driver)
- [Loader](#Loader)
- [Runtime](#Runtime)
- [Framework](#Framework)
- [Routerlicious-Driver](#Routerlicious-Driver) | ### Examples diff --git a/packages/framework/fluid-static/src/fluidContainer.ts b/packages/framework/fluid-static/src/fluidContainer.ts index d92a79230737..6cbe41abe806 100644 --- a/packages/framework/fluid-static/src/fluidContainer.ts +++ b/packages/framework/fluid-static/src/fluidContainer.ts @@ -265,6 +265,11 @@ export interface IFluidContainerInternal extends ContainerExtensionStore { * @remarks This method is used to expose uploadBlob to the IFluidContainer level. UploadBlob will upload data to server side (as of now, ODSP only). There is no downloadBlob provided as it is not needed(blob lifetime managed by server). */ uploadBlob(blob: ArrayBufferLike): Promise>; + + /** + * Serialize the container to a string representation. This can be saved for later rehydration. + */ + serialize(): string; } /** @@ -401,4 +406,13 @@ class FluidContainer public async uploadBlob(blob: ArrayBufferLike): Promise> { return this.rootDataObject.uploadBlob(blob); } + + public serialize(): string { + if (this.container.closed || this.container.attachState !== AttachState.Detached) { + throw new Error( + "Cannot serialize container. Container must be in detached state and not closed.", + ); + } + return this.container.serialize(); + } } diff --git a/packages/service-clients/odsp-client/api-report/odsp-client.alpha.api.md b/packages/service-clients/odsp-client/api-report/odsp-client.alpha.api.md index e519c7569499..8fd69b0095b5 100644 --- a/packages/service-clients/odsp-client/api-report/odsp-client.alpha.api.md +++ b/packages/service-clients/odsp-client/api-report/odsp-client.alpha.api.md @@ -7,6 +7,21 @@ // @beta export type IOdspAudience = IServiceAudience; +// @beta +export interface IOdspFluidContainer extends IFluidContainer { + attach(props?: ContainerAttachProps): Promise; + serialize(): string; + uploadBlob(blob: ArrayBufferLike): Promise>; +} + +// @beta +export interface IOdspFluidContainerEvents extends IEvent { + // (undocumented) + (event: "readOnlyStateChanged", listener: (readonly: boolean) => void): void; + // (undocumented) + (event: "sensitivityLabelChanged", listener: (sensitivityLabelsInfo: string) => void): void; +} + // @beta export interface IOdspTokenProvider { fetchStorageToken(siteUrl: string, refresh: boolean): Promise; @@ -26,6 +41,10 @@ export class OdspClient { container: IFluidContainer; services: OdspContainerServices; }>; + rehydrateContainer(serializedContainer: string, containerSchema: T): Promise<{ + container: IFluidContainer; + services: OdspContainerServices; + }>; } // @beta (undocumented) @@ -43,9 +62,20 @@ export interface OdspConnectionConfig { tokenProvider: IOdspTokenProvider; } +// @beta (undocumented) +export interface OdspContainerAttachProps { + eTag?: string; + fileName: string | undefined; + filePath: string | undefined; + itemId?: string; +} + // @beta -export interface OdspContainerServices { +export interface OdspContainerServices extends IEventProvider { audience: IOdspAudience; + dispose(): void; + getReadOnlyState(): boolean | undefined; + lookupTemporaryBlobURL(handle: IFluidHandle): string | undefined; } // @beta diff --git a/packages/service-clients/odsp-client/api-report/odsp-client.beta.api.md b/packages/service-clients/odsp-client/api-report/odsp-client.beta.api.md index 235a8f244202..58577a7d3bfe 100644 --- a/packages/service-clients/odsp-client/api-report/odsp-client.beta.api.md +++ b/packages/service-clients/odsp-client/api-report/odsp-client.beta.api.md @@ -7,6 +7,21 @@ // @beta export type IOdspAudience = IServiceAudience; +// @beta +export interface IOdspFluidContainer extends IFluidContainer { + attach(props?: ContainerAttachProps): Promise; + serialize(): string; + uploadBlob(blob: ArrayBufferLike): Promise>; +} + +// @beta +export interface IOdspFluidContainerEvents extends IEvent { + // (undocumented) + (event: "readOnlyStateChanged", listener: (readonly: boolean) => void): void; + // (undocumented) + (event: "sensitivityLabelChanged", listener: (sensitivityLabelsInfo: string) => void): void; +} + // @beta export interface IOdspTokenProvider { fetchStorageToken(siteUrl: string, refresh: boolean): Promise; @@ -26,6 +41,10 @@ export class OdspClient { container: IFluidContainer; services: OdspContainerServices; }>; + rehydrateContainer(serializedContainer: string, containerSchema: T): Promise<{ + container: IFluidContainer; + services: OdspContainerServices; + }>; } // @beta (undocumented) @@ -43,9 +62,20 @@ export interface OdspConnectionConfig { tokenProvider: IOdspTokenProvider; } +// @beta (undocumented) +export interface OdspContainerAttachProps { + eTag?: string; + fileName: string | undefined; + filePath: string | undefined; + itemId?: string; +} + // @beta -export interface OdspContainerServices { +export interface OdspContainerServices extends IEventProvider { audience: IOdspAudience; + dispose(): void; + getReadOnlyState(): boolean | undefined; + lookupTemporaryBlobURL(handle: IFluidHandle): string | undefined; } // @beta diff --git a/packages/service-clients/odsp-client/package.json b/packages/service-clients/odsp-client/package.json index 1c7dbd956a23..ecc6c79f45ca 100644 --- a/packages/service-clients/odsp-client/package.json +++ b/packages/service-clients/odsp-client/package.json @@ -104,8 +104,10 @@ "temp-directory": "nyc/.nyc_output" }, "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:~", "@fluidframework/driver-definitions": "workspace:~", @@ -114,6 +116,7 @@ "@fluidframework/odsp-doclib-utils": "workspace:~", "@fluidframework/odsp-driver": "workspace:~", "@fluidframework/odsp-driver-definitions": "workspace:~", + "@fluidframework/runtime-utils": "workspace:~", "@fluidframework/telemetry-utils": "workspace:~", "uuid": "^11.1.0" }, diff --git a/packages/service-clients/odsp-client/src/index.ts b/packages/service-clients/odsp-client/src/index.ts index 55c28a72e802..86cd5e52c7f2 100644 --- a/packages/service-clients/odsp-client/src/index.ts +++ b/packages/service-clients/odsp-client/src/index.ts @@ -14,10 +14,13 @@ */ export type { - OdspConnectionConfig, + IOdspAudience, + IOdspFluidContainerEvents, + IOdspFluidContainer, OdspClientProps, + OdspConnectionConfig, + OdspContainerAttachProps, OdspContainerServices, - IOdspAudience, OdspMember, TokenResponse, } from "./interfaces.js"; diff --git a/packages/service-clients/odsp-client/src/interfaces.ts b/packages/service-clients/odsp-client/src/interfaces.ts index a1b81f3640c0..a4662cc0833f 100644 --- a/packages/service-clients/odsp-client/src/interfaces.ts +++ b/packages/service-clients/odsp-client/src/interfaces.ts @@ -5,9 +5,18 @@ import type { IConfigProviderBase, + IEvent, + IEventProvider, + IFluidHandle, ITelemetryBaseLogger, } from "@fluidframework/core-interfaces"; -import type { IMember, IServiceAudience } from "@fluidframework/fluid-static"; +import type { + ContainerAttachProps, + ContainerSchema, + IFluidContainer, + IMember, + IServiceAudience, +} from "@fluidframework/fluid-static"; import type { IOdspTokenProvider } from "./token.js"; @@ -59,7 +68,6 @@ export interface OdspClientProps { } /** - * @legacy * @beta */ export interface OdspContainerAttachProps { @@ -72,6 +80,58 @@ export interface OdspContainerAttachProps { * The file name of the Fluid file. If undefined, the file is named with a GUID. */ fileName: string | undefined; + + /** + * The ID of the item (file) to which the container is being attached. + * When combined with eTag, this will trigger a conversion of an existing file to a Fluid file. + */ + itemId?: string; + + /** + * Optional eTag to use when attaching the container. + * If provided, the container will + */ + eTag?: string; +} + +/** + * Interface for the events emitted by the ODSP Fluid container services. + * @beta + */ +export interface IOdspFluidContainerEvents extends IEvent { + (event: "readOnlyStateChanged", listener: (readonly: boolean) => void): void; + (event: "sensitivityLabelChanged", listener: (sensitivityLabelsInfo: string) => void): void; +} + +/** + * ODSP version of the IFluidContainer interface. + * @beta + */ +export interface IOdspFluidContainer< + TContainerSchema extends ContainerSchema = ContainerSchema, +> extends IFluidContainer { + /** + * A newly created container starts detached from the collaborative service. + * Calling `attach()` uploads the new container to the service and connects to the collaborative service. + * + * This function is same as the IFluidContainer.attach function, but has ODSP specific function signatures. + * + * @param props - Optional properties to pass to the attach function. + * + * @returns A promise which resolves when the attach is complete, with the string identifier of the container. + */ + attach(props?: ContainerAttachProps): Promise; + + /** + * Upload a blob of data. + * @param blob - The blob to upload to the ODSP service. + */ + uploadBlob(blob: ArrayBufferLike): Promise>; + + /** + * Serialize the container to a string representation. This can be saved for later rehydration. + */ + serialize(): string; } /** @@ -82,11 +142,42 @@ export interface OdspContainerAttachProps { * use, will not be included here but rather on the FluidContainer class itself. * @beta */ -export interface OdspContainerServices { +export interface OdspContainerServices extends IEventProvider { /** * Provides an object that facilitates obtaining information about users present in the Fluid session, as well as listeners for roster changes triggered by users joining or leaving the session. */ audience: IOdspAudience; + + /** + * Get the read-only information about the container. + * + * @remarks + * + * This is used to determine if the container is read-only or not. + * Read-only is undefined on disconnected containers. + */ + getReadOnlyState(): boolean | undefined; + + /** + * Disposes the container services. + */ + dispose(): void; + + /** + * Lookup the blob URL for a blob handle. + * @param handle - The blob handle to lookup the URL for + * @returns The blob URL if found and the blob is not pending, undefined otherwise + * @remarks + * This function provides access to blob URLs for handles. + * The URL may expire and does not support permalinks. + * For blobs with pending payloads, this returns undefined. Consumers should use + * the observability APIs on the handle (handle.payloadState, payloadShared event) + * to understand/wait for URL availability. + * + * **WARNING**: This API comes with strong warnings that the URL may expire + * and does not support permalinks. + */ + lookupTemporaryBlobURL(handle: IFluidHandle): string | undefined; } /** diff --git a/packages/service-clients/odsp-client/src/odspClient.ts b/packages/service-clients/odsp-client/src/odspClient.ts index 725f72e7d079..23ed54b509d6 100644 --- a/packages/service-clients/odsp-client/src/odspClient.ts +++ b/packages/service-clients/odsp-client/src/odspClient.ts @@ -11,9 +11,12 @@ import type { import { createDetachedContainer, loadExistingContainer, + rehydrateDetachedContainer, type ILoaderProps, } from "@fluidframework/container-loader/internal"; +import type { IContainerRuntimeInternal } from "@fluidframework/container-runtime-definitions/internal"; import type { + FluidObject, IConfigProviderBase, IRequest, ITelemetryBaseLogger, @@ -28,6 +31,7 @@ import type { import { createDOProviderContainerRuntimeFactory, createFluidContainer, + isInternalFluidContainer, } from "@fluidframework/fluid-static/internal"; import { OdspDocumentServiceFactory, @@ -46,6 +50,7 @@ import type { OdspConnectionConfig, OdspContainerAttachProps, OdspContainerServices as IOdspContainerServices, + IOdspFluidContainer, } from "./interfaces.js"; import { OdspContainerServices } from "./odspContainerServices.js"; import type { IOdspTokenProvider } from "./token.js"; @@ -77,7 +82,7 @@ async function getWebsocketToken( * These values will only be used if the feature gate is not already set by the supplied config provider. */ const odspClientFeatureGates = { - // None yet + "Fluid.Driver.Odsp.enableLargeBlobUpload": true, }; /** @@ -131,7 +136,34 @@ export class OdspClient { const fluidContainer = await this.createFluidContainer(container, this.connectionConfig); - const services = await this.getContainerServices(container); + const services = await this.getContainerServices(container, fluidContainer); + + return { container: fluidContainer as IFluidContainer, services }; + } + + /** + * Create a container from the serialized state of a detached container. + * + * @param serializedContainer - Serialized string representation of the container. + * @param containerSchema - The schema of the container to rehydrate. + */ + public async rehydrateContainer( + serializedContainer: string, + containerSchema: T, + ): Promise<{ + container: IFluidContainer; + services: IOdspContainerServices; + }> { + const loaderProps = this.getLoaderProps(containerSchema); + + const container = await rehydrateDetachedContainer({ + ...loaderProps, + serializedState: serializedContainer, + }); + + const fluidContainer = await this.createFluidContainer(container, this.connectionConfig); + + const services = await this.getContainerServices(container, fluidContainer); return { container: fluidContainer as IFluidContainer, services }; } @@ -155,7 +187,7 @@ export class OdspClient { const fluidContainer = await createFluidContainer({ container, }); - const services = await this.getContainerServices(container); + const services = await this.getContainerServices(container, fluidContainer); return { container: fluidContainer as IFluidContainer, services }; } @@ -195,19 +227,32 @@ export class OdspClient { private async createFluidContainer( container: IContainer, connection: OdspConnectionConfig, - ): Promise { + ): Promise { /** * See {@link FluidContainer.attach} */ const attach = async ( odspProps?: ContainerAttachProps, ): Promise => { - const createNewRequest: IRequest = createOdspCreateContainerRequest( - connection.siteUrl, - connection.driveId, - odspProps?.filePath ?? "", - odspProps?.fileName ?? uuid(), - ); + const createNewRequest: IRequest = + odspProps?.eTag !== undefined && odspProps?.itemId !== undefined + ? { + url: createOdspUrl({ + siteUrl: connection.siteUrl, + driveId: connection.driveId, + itemId: odspProps.itemId, + dataStorePath: "", + }), + headers: { + eTag: odspProps.eTag, + }, + } + : createOdspCreateContainerRequest( + connection.siteUrl, + connection.driveId, + odspProps?.filePath ?? "", + odspProps?.fileName ?? uuid(), + ); if (container.attachState !== AttachState.Detached) { throw new Error("Cannot attach container. Container is not in detached state"); } @@ -228,10 +273,51 @@ export class OdspClient { }; const fluidContainer = await createFluidContainer({ container }); fluidContainer.attach = attach; + if (!isInternalFluidContainer(fluidContainer)) { + throw new Error( + "Unexpected Fluid container type, the returned container is not FluidContainer type.", + ); + } return fluidContainer; } - private async getContainerServices(container: IContainer): Promise { - return new OdspContainerServices(container); + private async getRuntimeInternal( + container: IContainer, + ): Promise { + const entryPoint = await container.getEntryPoint(); + if ( + entryPoint !== undefined && + typeof (entryPoint as IMaybeFluidObjectWithContainerRuntime).IStaticEntryPoint + ?.extensionStore?.lookupTemporaryBlobStorageId === "function" + ) { + // If the container has a static entry point with an extension store, use that to get the runtime + return (entryPoint as IMaybeFluidObjectWithContainerRuntime).IStaticEntryPoint + .extensionStore; + } } + + private async getContainerServices( + container: IContainer, + _fluidContainer: IFluidContainer, + ): Promise { + // Get the runtime access upfront + const runtimeInternal = await this.getRuntimeInternal(container); + // Get the resolved URL for ODSP-specific URL building + const resolvedUrl = container.resolvedUrl; + const odspResolvedUrl = + resolvedUrl && isOdspResolvedUrl(resolvedUrl) ? resolvedUrl : undefined; + return new OdspContainerServices(container, odspResolvedUrl, runtimeInternal); + } +} + +/** + * Disclaimer: Hack!! + * This is a temporary interface to expose the internal container runtime from Container. + * This is needed to access the `lookupBlobStorageId` API on the runtime. + */ +interface IMaybeFluidObjectWithContainerRuntime extends FluidObject { + IStaticEntryPoint: { + rootDataObject: unknown; + extensionStore: IContainerRuntimeInternal; + }; } diff --git a/packages/service-clients/odsp-client/src/odspContainerServices.ts b/packages/service-clients/odsp-client/src/odspContainerServices.ts index 10cf8926293e..edc281771ed9 100644 --- a/packages/service-clients/odsp-client/src/odspContainerServices.ts +++ b/packages/service-clients/odsp-client/src/odspContainerServices.ts @@ -3,25 +3,124 @@ * Licensed under the MIT License. */ +import { TypedEventEmitter } from "@fluid-internal/client-utils"; import type { IContainer } from "@fluidframework/container-definitions/internal"; +import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; +import type { IFluidHandle } from "@fluidframework/core-interfaces"; import { createServiceAudience } from "@fluidframework/fluid-static/internal"; +import type { IOdspResolvedUrl } from "@fluidframework/odsp-driver-definitions/internal"; +import { lookupTemporaryBlobStorageId } from "@fluidframework/runtime-utils/internal"; import type { IOdspAudience, OdspContainerServices as IOdspContainerServices, + IOdspFluidContainerEvents, } from "./interfaces.js"; import { createOdspAudienceMember } from "./odspAudience.js"; +/** + * Helper function to build a blob URL from a storage ID using ODSP-specific logic + * @param storageId - The storage ID of the blob + * @param resolvedUrl - The ODSP resolved URL containing endpoint information + * @returns The blob URL if it can be built, undefined otherwise + */ +function buildOdspBlobUrl( + storageId: string, + resolvedUrl: IOdspResolvedUrl, +): string | undefined { + try { + const attachmentGETUrl = resolvedUrl.endpoints.attachmentGETStorageUrl; + if (!attachmentGETUrl) { + return undefined; + } + return `${attachmentGETUrl}/${encodeURIComponent(storageId)}/content`; + } catch { + return undefined; + } +} + +/** + * Helper function for ODSPClient to lookup blob URLs + * @param runtimeInternal - The container runtime internal interface + * @param handle - The blob handle to lookup the URL for + * @param resolvedUrl - The ODSP resolved URL containing endpoint information + * @returns The blob URL if found and the blob is not pending, undefined otherwise + */ +function lookupOdspBlobURL( + runtimeInternal: IContainerRuntime, + handle: IFluidHandle, + resolvedUrl: IOdspResolvedUrl, +): string | undefined { + try { + if ( + runtimeInternal !== undefined && + typeof (runtimeInternal as { lookupTemporaryBlobStorageId?: unknown }) + .lookupTemporaryBlobStorageId === "function" + ) { + // Get the storage ID from the runtime + const storageId = lookupTemporaryBlobStorageId(runtimeInternal, handle); + if (storageId === undefined) { + return undefined; + } + + // Build the URL using ODSP-specific logic + return buildOdspBlobUrl(storageId, resolvedUrl); + } + return undefined; + } catch { + return undefined; + } +} + /** * @internal */ -export class OdspContainerServices implements IOdspContainerServices { - public readonly audience: IOdspAudience; +export class OdspContainerServices + extends TypedEventEmitter + implements IOdspContainerServices +{ + private readonly readonlyHandler = (readonly: boolean): void => { + this.emit("readOnlyStateChanged", readonly); + }; + private readonly metadataUpdateHandler = (metadata: Record): void => { + if (metadata.sensitivityLabelsInfo !== undefined) { + this.emit("sensitivityLabelChanged", metadata.sensitivityLabelsInfo); + } + }; + private readonly audienceInternal: IOdspAudience; - public constructor(container: IContainer) { - this.audience = createServiceAudience({ - container, + public constructor( + private readonly container: IContainer, + private readonly odspResolvedUrl?: IOdspResolvedUrl, + private readonly containerRuntimeInternal?: IContainerRuntime, + ) { + super(); + this.container.on("readonly", this.readonlyHandler); + this.container.on("metadataUpdate", this.metadataUpdateHandler); + this.audienceInternal = createServiceAudience({ + container: this.container, createServiceMember: createOdspAudienceMember, }); } + + public get audience(): IOdspAudience { + return this.audienceInternal; + } + + public getReadOnlyState(): boolean | undefined { + return this.container.readOnlyInfo.readonly; + } + + public dispose(): void { + this.container.off("readonly", this.readonlyHandler); + this.removeAllListeners(); + } + + public lookupTemporaryBlobURL(handle: IFluidHandle): string | undefined { + if (!this.odspResolvedUrl || this.containerRuntimeInternal === undefined) { + // Can't build URLs without ODSP resolved URL information + return undefined; + } + return lookupOdspBlobURL(this.containerRuntimeInternal, handle, this.odspResolvedUrl); + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80993c9c65bd..d0b73b80d89e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13540,12 +13540,18 @@ importers: packages/service-clients/odsp-client: dependencies: + '@fluid-internal/client-utils': + specifier: workspace:~ + version: link:../../common/client-utils '@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 '@fluidframework/core-interfaces': specifier: workspace:~ version: link:../../common/core-interfaces @@ -13570,6 +13576,9 @@ importers: '@fluidframework/odsp-driver-definitions': specifier: workspace:~ version: link:../../drivers/odsp-driver-definitions + '@fluidframework/runtime-utils': + specifier: workspace:~ + version: link:../../runtime/runtime-utils '@fluidframework/telemetry-utils': specifier: workspace:~ version: link:../../utils/telemetry-utils