/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/
import * as moniker from "moniker";
import { v4 as uuid } from "uuid";
import { ContainerRuntimeFactoryWithDefaultDataStore } from "@fluidframework/aqueduct";
import { assert, BaseTelemetryNullLogger, Deferred } from "@fluidframework/common-utils";
import {
AttachState,
IFluidModule,
IFluidCodeResolver,
IResolvedFluidCodeDetails,
isFluidBrowserPackage,
} from "@fluidframework/container-definitions";
import { Container, Loader } from "@fluidframework/container-loader";
import { prefetchLatestSnapshot } from "@fluidframework/odsp-driver";
import { IPersistedCache } from "@fluidframework/odsp-driver-definitions";
import { IUser } from "@fluidframework/protocol-definitions";
import { HTMLViewAdapter } from "@fluidframework/view-adapters";
import { IFluidMountableView } from "@fluidframework/view-interfaces";
import {
extractPackageIdentifierDetails,
resolveFluidPackageEnvironment,
WebCodeLoader,
} from "@fluidframework/web-code-loader";
import { IFluidObject, IFluidPackage, IFluidCodeDetails } from "@fluidframework/core-interfaces";
import { IDocumentServiceFactory } from "@fluidframework/driver-definitions";
import { LocalDocumentServiceFactory, LocalResolver } from "@fluidframework/local-driver";
import { RequestParser, createDataStoreFactory } from "@fluidframework/runtime-utils";
import { MultiUrlResolver } from "./multiResolver";
import { deltaConns, getDocumentServiceFactory } from "./multiDocumentServiceFactory";
import { OdspPersistentCache } from "./odspPersistantCache";
export interface IDevServerUser extends IUser {
name: string;
}
export interface IBaseRouteOptions {
port: number;
npm?: string;
}
export interface ILocalRouteOptions extends IBaseRouteOptions {
mode: "local";
single?: boolean;
}
export interface IDockerRouteOptions extends IBaseRouteOptions {
mode: "docker";
tenantId?: string;
tenantSecret?: string;
bearerSecret?: string;
}
export interface IRouterliciousRouteOptions extends IBaseRouteOptions {
mode: "r11s";
fluidHost?: string;
tenantId?: string;
tenantSecret?: string;
bearerSecret?: string;
}
export interface ITinyliciousRouteOptions extends IBaseRouteOptions {
mode: "tinylicious";
bearerSecret?: string;
tinyliciousPort?: number;
}
export interface IOdspRouteOptions extends IBaseRouteOptions {
mode: "spo" | "spo-df";
server?: string;
odspAccessToken?: string;
pushAccessToken?: string;
forceReauth?: boolean;
driveId?: string;
}
export type RouteOptions =
| ILocalRouteOptions
| IDockerRouteOptions
| IRouterliciousRouteOptions
| ITinyliciousRouteOptions
| IOdspRouteOptions;
function wrapWithRuntimeFactoryIfNeeded(packageJson: IFluidPackage, fluidModule: IFluidModule): IFluidModule {
if (fluidModule.fluidExport.IRuntimeFactory === undefined) {
const dataStoreFactory = fluidModule.fluidExport.IFluidDataStoreFactory;
const defaultFactory = createDataStoreFactory(packageJson.name, dataStoreFactory);
const runtimeFactory = new ContainerRuntimeFactoryWithDefaultDataStore(
defaultFactory,
new Map([
[defaultFactory.type, Promise.resolve(defaultFactory)],
]),
);
return {
fluidExport: {
IRuntimeFactory: runtimeFactory,
IFluidDataStoreFactory: dataStoreFactory,
},
};
}
return fluidModule;
}
// Invoked by `start()` when the 'double' option is enabled to create the side-by-side panes.
function makeSideBySideDiv(divId: string) {
const div = document.createElement("div");
div.style.flexGrow = "1";
div.style.width = "50%"; // ensure the divs don't encroach on each other
div.style.border = "1px solid lightgray";
div.style.boxSizing = "border-box";
div.style.position = "relative"; // Make the new
a CSS containing block.
div.id = divId;
return div;
}
class WebpackCodeResolver implements IFluidCodeResolver {
constructor(private readonly options: IBaseRouteOptions) { }
async resolveCodeDetails(details: IFluidCodeDetails): Promise {
const baseUrl = details.config.cdn ?? `http://localhost:${this.options.port}`;
let pkg = details.package;
if (typeof pkg === "string") {
const resp = await fetch(`${baseUrl}/package.json`);
pkg = await resp.json() as IFluidPackage;
}
if (!isFluidBrowserPackage(pkg)) {
throw new Error("Not a Fluid package");
}
const browser =
resolveFluidPackageEnvironment(pkg.fluid.browser, baseUrl);
const parse = extractPackageIdentifierDetails(pkg);
return {
...details,
resolvedPackage: {
...pkg,
fluid: {
...pkg.fluid,
browser,
},
},
resolvedPackageCacheId: parse.fullId,
};
}
}
/**
* Create a loader with WebCodeLoader and return it.
*/
async function createWebLoader(
documentId: string,
fluidModule: IFluidModule,
options: RouteOptions,
urlResolver: MultiUrlResolver,
codeDetails: IFluidCodeDetails,
testOrderer: boolean = false,
odspPersistantCache?: IPersistedCache,
): Promise {
let documentServiceFactory: IDocumentServiceFactory =
getDocumentServiceFactory(documentId, options, odspPersistantCache);
// Create the inner document service which will be wrapped inside local driver. The inner document service
// will be used for ops(like delta connection/delta ops) while for storage, local storage would be used.
if (testOrderer) {
const resolvedUrl = await urlResolver.resolve(await urlResolver.createRequestForCreateNew(documentId));
const innerDocumentService = await documentServiceFactory.createDocumentService(resolvedUrl);
documentServiceFactory = new LocalDocumentServiceFactory(
deltaConns.get(documentId),
undefined,
innerDocumentService);
}
const codeLoader = new WebCodeLoader(new WebpackCodeResolver(options));
await codeLoader.seedModule(
codeDetails,
wrapWithRuntimeFactoryIfNeeded(codeDetails.package as IFluidPackage, fluidModule),
);
return new Loader({
urlResolver: testOrderer ?
new MultiUrlResolver(documentId, window.location.origin, options, true) : urlResolver,
documentServiceFactory,
codeLoader,
});
}
const containers: Container[] = [];
// A function for testing to make sure the containers are not dirty and in sync (at the same seq num)
export function isSynchronized() {
if (containers.length === 0) { return true; }
const seqNum = containers[0].deltaManager.lastSequenceNumber;
return containers.every((c) => !c.isDirty && c.deltaManager.lastSequenceNumber === seqNum);
}
export async function start(
id: string,
packageJson: IFluidPackage,
fluidModule: IFluidModule,
options: RouteOptions,
div: HTMLDivElement,
): Promise {
let documentId: string = id;
let url = window.location.href;
/**
* For new documents, the `url` is of the format - http://localhost:8080/new or http://localhost:8080/manualAttach.
* So, we create a new `id` and use that as the `documentId`.
* We will also replace the url in the browser with a new url of format - http://localhost:8080/doc/.
*/
const autoAttach: boolean = id === "new" || id === "testorderer";
const manualAttach: boolean = id === "manualAttach";
const testOrderer = id === "testorderer";
if (autoAttach || manualAttach) {
documentId = moniker.choose();
url = url.replace(id, `doc/${documentId}`);
}
const codeDetails: IFluidCodeDetails = {
package: packageJson,
config: {},
};
const urlResolver = new MultiUrlResolver(documentId, window.location.origin, options);
const odspPersistantCache = new OdspPersistentCache();
// Create the loader that is used to load the Container.
const loader1 = await createWebLoader(
documentId,
fluidModule,
options,
urlResolver,
codeDetails,
testOrderer,
odspPersistantCache);
let container1: Container;
if (autoAttach || manualAttach) {
// For new documents, create a detached container which will be attached later.
container1 = await loader1.createDetachedContainer(codeDetails);
containers.push(container1);
} else {
// For existing documents, we try to load the container with the given documentId.
const documentUrl = `${window.location.origin}/${documentId}`;
// This functionality is used in odsp driver to prefetch the latest snapshot and cache it so
// as to avoid the network call to fetch trees latest.
if (window.location.hash === "#prefetch") {
assert(options.mode === "spo-df" || options.mode === "spo",
0x1ea /* "Prefetch snapshot only available for odsp!" */);
const prefetched = await prefetchLatestSnapshot(
await urlResolver.resolve({ url: documentUrl }),
async () => options.odspAccessToken,
odspPersistantCache,
new BaseTelemetryNullLogger(),
undefined,
);
assert(prefetched, 0x1eb /* "Snapshot should be prefetched!" */);
}
container1 = await loader1.resolve({ url: documentUrl });
containers.push(container1);
}
let leftDiv: HTMLDivElement = div;
let rightDiv: HTMLDivElement | undefined;
// For side by side mode, create two divs. Use side by side mode to test orderer.
if ((options.mode === "local" && !options.single) || testOrderer) {
div.style.display = "flex";
leftDiv = makeSideBySideDiv("sbs-left");
rightDiv = makeSideBySideDiv("sbs-right");
div.append(leftDiv, rightDiv);
}
const reqParser = RequestParser.create({ url });
const fluidObjectUrl = `/${reqParser.createSubRequest(4).url}`;
// Load and render the Fluid object.
await getFluidObjectAndRender(container1, fluidObjectUrl, leftDiv);
// Handle the code upgrade scenario (which fires contextChanged)
container1.on("contextChanged", () => {
getFluidObjectAndRender(container1, fluidObjectUrl, leftDiv).catch(() => { });
});
// We have rendered the Fluid object. If the container is detached, attach it now.
if (container1.attachState === AttachState.Detached) {
container1 = await attachContainer(
loader1,
container1,
fluidObjectUrl,
urlResolver,
documentId,
url,
leftDiv,
rightDiv,
manualAttach,
testOrderer,
);
}
// For side by side mode, we need to create a second container and Fluid object.
if (rightDiv) {
// Create a new loader that is used to load the second container.
const loader2 = await createWebLoader(documentId, fluidModule, options, urlResolver, codeDetails, testOrderer);
// Create a new request url from the resolvedUrl of the first container.
const requestUrl2 = await urlResolver.getAbsoluteUrl(container1.resolvedUrl, "");
const container2 = await loader2.resolve({ url: requestUrl2 });
containers.push(container2);
await getFluidObjectAndRender(container2, fluidObjectUrl, rightDiv);
// Handle the code upgrade scenario (which fires contextChanged)
container2.on("contextChanged", () => {
getFluidObjectAndRender(container2, fluidObjectUrl, rightDiv).catch(() => { });
});
}
}
async function getFluidObjectAndRender(container: Container, url: string, div: HTMLDivElement) {
const response = await container.request({
headers: {
mountableView: true,
},
url,
});
if (response.status !== 200 ||
!(
response.mimeType === "fluid/object"
)) {
return false;
}
const fluidObject = response.value as IFluidObject;
if (fluidObject === undefined) {
return;
}
// We should be retaining a reference to mountableView long-term, so we can call unmount() on it to correctly
// remove it from the DOM if needed.
const mountableView: IFluidMountableView = fluidObject.IFluidMountableView;
if (mountableView !== undefined) {
mountableView.mount(div);
return;
}
// If we don't get a mountable view back, we can still try to use a view adapter. This won't always work (e.g.
// if the response is a React-based Fluid object using hooks) and is not the preferred path, but sometimes it
// can work.
console.warn(`Container returned a non-IFluidMountableView. This can cause errors when mounting Fluid objects `
+ `with React hooks across bundle boundaries. URL: ${url}`);
const view = new HTMLViewAdapter(fluidObject);
view.render(div, { display: "block" });
}
/**
* Attached a detached container.
* In case of manual attach (when manualAttach is true), it creates a button and attaches the container when the button
* is clicked. Otherwise, it attaches the container right away.
*/
async function attachContainer(
loader: Loader,
container: Container,
fluidObjectUrl: string,
urlResolver: MultiUrlResolver,
documentId: string,
url: string,
leftDiv: HTMLDivElement,
rightDiv: HTMLDivElement | undefined,
manualAttach: boolean,
testOrderer: boolean,
) {
// This is called once loading is complete to replace the url in the address bar with the new `url`.
const replaceUrl = () => {
window.history.replaceState({}, "", url);
document.title = documentId;
};
let currentContainer = container;
let currentLeftDiv = leftDiv;
const attached = new Deferred();
// To test orderer, we use local driver as wrapper for actual document service. So create request
// using local resolver.
const attachUrl = testOrderer ? new LocalResolver().createCreateNewRequest(documentId)
: await urlResolver.createRequestForCreateNew(documentId);
if (manualAttach) {
// Create an "Attach Container" button that the user can click when they want to attach the container.
const attachDiv = document.createElement("div");
const attachButton = document.createElement("button");
attachButton.innerText = "Attach Container";
const serializeButton = document.createElement("button");
serializeButton.innerText = "Serialize";
const rehydrateButton = document.createElement("button");
rehydrateButton.innerText = "Rehydrate Container";
rehydrateButton.hidden = true;
const summaryList = document.createElement("select");
summaryList.hidden = true;
attachDiv.append(attachButton);
attachDiv.append(serializeButton);
attachDiv.append(summaryList);
document.body.prepend(attachDiv);
let summaryNum = 1;
serializeButton.onclick = () => {
summaryList.hidden = false;
rehydrateButton.hidden = false;
attachDiv.append(rehydrateButton);
const summary = currentContainer.serialize();
const listItem = document.createElement("option");
listItem.innerText = `Summary_${summaryNum}`;
summaryNum += 1;
listItem.value = summary;
summaryList.appendChild(listItem);
rehydrateButton.onclick = async () => {
const snapshot = summaryList.value;
currentContainer = await loader.rehydrateDetachedContainerFromSnapshot(snapshot);
let newLeftDiv: HTMLDivElement;
if (rightDiv !== undefined) {
newLeftDiv = makeSideBySideDiv(uuid());
} else {
newLeftDiv = document.createElement("div");
}
currentLeftDiv.replaceWith(newLeftDiv);
currentLeftDiv = newLeftDiv;
// Load and render the component.
await getFluidObjectAndRender(currentContainer, fluidObjectUrl, newLeftDiv);
// Handle the code upgrade scenario (which fires contextChanged)
currentContainer.on("contextChanged", () => {
getFluidObjectAndRender(currentContainer, fluidObjectUrl, newLeftDiv).catch(() => { });
});
};
};
attachButton.onclick = () => {
currentContainer.attach(attachUrl)
.then(() => {
attachDiv.remove();
replaceUrl();
if (rightDiv) {
rightDiv.innerText = "";
}
attached.resolve();
}, (error) => {
console.error(error);
});
};
// If we are in side-by-side mode, we need to display the following message in the right div passed here.
if (rightDiv) {
rightDiv.innerText = "Waiting for container attach";
}
} else {
await currentContainer.attach(attachUrl);
replaceUrl();
attached.resolve();
}
await attached.promise;
return currentContainer;
}