diff --git a/packages/client-config-builder/src/ClientConfigBuilder.ts b/packages/client-config-builder/src/ClientConfigBuilder.ts index 9ceb41f01..b32d0ea73 100644 --- a/packages/client-config-builder/src/ClientConfigBuilder.ts +++ b/packages/client-config-builder/src/ClientConfigBuilder.ts @@ -17,7 +17,6 @@ import { StaticResolver, ResolutionResultCache, ResolutionResultCacheResolver, - PackageToWrapperResolver, RequestSynchronizerResolver, } from "@polywrap/uri-resolvers-js"; import { ExtendableUriResolver } from "@polywrap/uri-resolver-extensions-js"; @@ -50,7 +49,7 @@ export class ClientConfigBuilder extends BaseClientConfigBuilder { RecursiveResolver.from( RequestSynchronizerResolver.from( ResolutionResultCacheResolver.from( - PackageToWrapperResolver.from([ + [ StaticResolver.from([ ...this.buildRedirects(), ...this.buildWrappers(), @@ -58,7 +57,7 @@ export class ClientConfigBuilder extends BaseClientConfigBuilder { ]), ...this._config.resolvers, new ExtendableUriResolver(), - ]), + ], resolutionResultCache ?? new ResolutionResultCache() ) ) diff --git a/packages/client/src/__tests__/core/error-structure.spec.ts b/packages/client/src/__tests__/core/error-structure.spec.ts index fe366cd8f..682e5713a 100644 --- a/packages/client/src/__tests__/core/error-structure.spec.ts +++ b/packages/client/src/__tests__/core/error-structure.spec.ts @@ -256,9 +256,8 @@ describe("Error structure", () => { if (result.ok) throw Error("should never happen"); expect(result.error?.name).toEqual("WrapError"); - expect(result.error?.code).toEqual(WrapErrorCode.URI_RESOLVER_ERROR); + expect(result.error?.code).toEqual(WrapErrorCode.CLIENT_LOAD_WRAPPER_ERROR); expect(result.error?.uri.endsWith("tmp")).toBeTruthy(); - expect(result.error?.resolutionStack).toBeDefined(); expect(`${result.error?.cause}`).toContain( `Unrecognized WrapManifest schema version "0.0.0.5"` ); diff --git a/packages/client/src/__tests__/core/uri-resolution.spec.ts b/packages/client/src/__tests__/core/uri-resolution.spec.ts index 4e233f5bc..7adc14e1a 100644 --- a/packages/client/src/__tests__/core/uri-resolution.spec.ts +++ b/packages/client/src/__tests__/core/uri-resolution.spec.ts @@ -41,7 +41,7 @@ describe("URI resolution", () => { ); if (expectResult.ok) { - expectResult.value.type = "wrapper"; + expectResult.value.type = "package"; } await expectResolutionResult( diff --git a/packages/uri-resolver-extensions/package.json b/packages/uri-resolver-extensions/package.json index 6b44fd830..912c3998e 100644 --- a/packages/uri-resolver-extensions/package.json +++ b/packages/uri-resolver-extensions/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@polywrap/cli-js": "0.10.0", + "@polywrap/client-js": "0.10.0", "@polywrap/core-client-js": "0.10.0", "@polywrap/plugin-js": "0.10.0", "@polywrap/test-cases": "0.10.0", diff --git a/packages/uri-resolver-extensions/src/UriResolverExtensionFileReader.ts b/packages/uri-resolver-extensions/src/UriResolverExtensionFileReader.ts index 88895da8b..d4792b4e0 100644 --- a/packages/uri-resolver-extensions/src/UriResolverExtensionFileReader.ts +++ b/packages/uri-resolver-extensions/src/UriResolverExtensionFileReader.ts @@ -13,6 +13,11 @@ import { Result, ResultErr } from "@polywrap/result"; // $start: UriResolverExtensionFileReader /** An IFileReader that reads files by invoking URI Resolver Extension wrappers */ export class UriResolverExtensionFileReader implements IFileReader /* $ */ { + private _fileCache: Map< + string, + Promise> + > = new Map(); + // $start: UriResolverExtensionFileReader-constructor /** * Construct a UriResolverExtensionFileReader @@ -37,30 +42,58 @@ export class UriResolverExtensionFileReader implements IFileReader /* $ */ { * */ async readFile(filePath: string): Promise> /* $ */ { const path = combinePaths(this._wrapperUri.path, filePath); - const result = await UriResolverInterface.module.getFile( - { - invoke: ( - options: InvokeOptions - ): Promise> => this._client.invoke(options), - invokeWrapper: ( - options: InvokeOptions & { wrapper: Wrapper } - ): Promise> => - this._client.invokeWrapper(options), - }, - this._resolverExtensionUri, - path - ); - if (!result.ok) return result; - if (!result.value) { - return ResultErr( - new Error( - `File not found at ${path} using resolver ${this._resolverExtensionUri.uri}` - ) - ); + + // If the file has already been requested + const existingFile = this._fileCache.get(path); + + if (existingFile) { + return existingFile; } - return { - value: result.value, - ok: true, - }; + + // else, create a new read file request + const getFileRequest = new Promise>((resolve) => { + return UriResolverInterface.module + .getFile( + { + invoke: ( + options: InvokeOptions + ): Promise> => + this._client.invoke(options), + invokeWrapper: ( + options: InvokeOptions & { wrapper: Wrapper } + ): Promise> => + this._client.invokeWrapper(options), + }, + this._resolverExtensionUri, + path + ) + .then((result) => { + if (!result.ok) { + // The UriResolver has encountered an error, + // return the error & reset the file cache (enabling retries). + this._fileCache.delete(path); + resolve(result); + } else if (!result.value) { + // The UriResolver did not find the file @ the provided URI. + resolve( + ResultErr( + new Error( + `File not found at ${path} using resolver ${this._resolverExtensionUri.uri}` + ) + ) + ); + } else { + // The file has been found. + resolve({ + value: result.value, + ok: true, + }); + } + }); + }); + + this._fileCache.set(path, getFileRequest); + + return getFileRequest; } } diff --git a/packages/uri-resolver-extensions/src/__tests__/uri-resolver-extension-file-reader.spec.ts b/packages/uri-resolver-extensions/src/__tests__/uri-resolver-extension-file-reader.spec.ts new file mode 100644 index 000000000..0771108f4 --- /dev/null +++ b/packages/uri-resolver-extensions/src/__tests__/uri-resolver-extension-file-reader.spec.ts @@ -0,0 +1,155 @@ +import { UriResolverExtensionFileReader } from "../UriResolverExtensionFileReader"; + +import { + PolywrapClient, + ClientConfigBuilder, + Uri +} from "@polywrap/client-js"; +import { + PluginModule, + PluginWrapper +} from "@polywrap/plugin-js"; + +const mockUriResolverExtUri = "wrap://mock/uri-resolver-ext"; +const mockFile = Uint8Array.from([0, 1, 2]); + +class MockUriResolverExt extends PluginModule<{}, {}> { + callCount: number = 0; + + getCallCount() { + return this.callCount; + } + + getFile(args: { path: string }) { + ++this.callCount; + if (args.path.includes("throw/now")) { + throw Error("failed during read file"); + } else if (args.path.includes("not/found")) { + return null; + } + return mockFile; + } +} + +function createMockClient(): PolywrapClient { + const config = new ClientConfigBuilder() + .addWrapper( + mockUriResolverExtUri, + new PluginWrapper( + { version: "0.1", type: "plugin", name: "counter", abi: {} }, + new MockUriResolverExt({}) + ) + ) + .build(); + return new PolywrapClient(config); +} + +function createUriResolverExtensionFileReader( + mockClient: PolywrapClient +): UriResolverExtensionFileReader { + return new UriResolverExtensionFileReader( + Uri.from(mockUriResolverExtUri), + Uri.from("wrap://foo/bar"), + mockClient + ); +} + +async function getCallCount(client: PolywrapClient): Promise { + const result = await client.invoke({ + uri: mockUriResolverExtUri, + method: "getCallCount" + }); + + if (!result.ok) throw result.error; + return result.value; +} + +describe("UriResolverExtensionFileReader", () => { + it("resolves a file", async () => { + const mockClient = createMockClient(); + const fileReader = createUriResolverExtensionFileReader(mockClient); + + const file = await fileReader.readFile("some/path"); + expect(file.ok).toBeTruthy(); + if (!file.ok) throw file.error; + expect(file.value).toMatchObject(mockFile); + }); + + it("caches files", async () => { + const mockClient = createMockClient(); + const fileReader = createUriResolverExtensionFileReader(mockClient); + + const file1 = await fileReader.readFile("some/path"); + expect(file1.ok).toBeTruthy(); + if (!file1.ok) throw file1.error; + expect(file1.value).toMatchObject(mockFile); + + // Ensure the call counter is 1 + expect(await getCallCount(mockClient)).toBe(1); + + // Call again + const file2 = await fileReader.readFile("some/path"); + expect(file2.ok).toBeTruthy(); + if (!file2.ok) throw file2.error; + expect(file2.value).toMatchObject(mockFile); + + // Ensure the call counter is still 1 + expect(await getCallCount(mockClient)).toBe(1); + }); + + it("can synchronize parallel requests", async () => { + const mockClient = createMockClient(); + const fileReader = createUriResolverExtensionFileReader(mockClient); + + const results = await Promise.all([ + fileReader.readFile("some/path"), + fileReader.readFile("some/path") + ]); + + for (const result of results) { + expect(result.ok).toBeTruthy(); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject(mockFile); + } + + // Ensure the call counter is 1 + expect(await getCallCount(mockClient)).toBe(1); + }); + + it("can retry when an error is thrown", async () => { + const mockClient = createMockClient(); + const fileReader = createUriResolverExtensionFileReader(mockClient); + + // It returns an error + const file1 = await fileReader.readFile("throw/now"); + expect(file1.ok).toBeFalsy(); + + // Ensure the call counter is 1 + expect(await getCallCount(mockClient)).toBe(1); + + // Call again + const file2 = await fileReader.readFile("throw/now"); + expect(file2.ok).toBeFalsy(); + + // Ensure the call counter is now 2 + expect(await getCallCount(mockClient)).toBe(2); + }); + + it("caches result when not found", async () => { + const mockClient = createMockClient(); + const fileReader = createUriResolverExtensionFileReader(mockClient); + + const file1 = await fileReader.readFile("not/found"); + expect(file1.ok).toBeFalsy(); + + // Ensure the call counter is 1 + expect(await getCallCount(mockClient)).toBe(1); + + // Call again + const file2 = await fileReader.readFile("not/found"); + expect(file2.ok).toBeFalsy(); + + // Ensure the call counter is still 1 + expect(await getCallCount(mockClient)).toBe(1); + }); +});