From 3793c51a3ce36649f60def6ff5614b6d17e9b86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Thu, 28 Mar 2024 15:29:25 +0000 Subject: [PATCH] feat: add experimental support for region selection --- src/client.ts | 23 +++++- src/environment.ts | 1 + src/main.test.ts | 178 +++++++++++++++++++++++++++++++++++++++++++ src/store_factory.ts | 27 +++++++ 4 files changed, 225 insertions(+), 4 deletions(-) diff --git a/src/client.ts b/src/client.ts index ffcf94c..eb7d06e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -27,6 +27,10 @@ export interface ClientOptions { uncachedEdgeURL?: string } +interface InternalClientOptions extends ClientOptions { + region?: string +} + interface GetFinalRequestOptions { consistency?: ConsistencyMode key: string | undefined @@ -41,15 +45,17 @@ export class Client { private consistency: ConsistencyMode private edgeURL?: string private fetch: Fetcher + private region?: string private siteID: string private token: string private uncachedEdgeURL?: string - constructor({ apiURL, consistency, edgeURL, fetch, siteID, token, uncachedEdgeURL }: ClientOptions) { + constructor({ apiURL, consistency, edgeURL, fetch, region, siteID, token, uncachedEdgeURL }: InternalClientOptions) { this.apiURL = apiURL this.consistency = consistency ?? 'eventual' this.edgeURL = edgeURL this.fetch = fetch ?? globalThis.fetch + this.region = region this.siteID = siteID this.token = token this.uncachedEdgeURL = uncachedEdgeURL @@ -95,6 +101,10 @@ export class Client { headers[METADATA_HEADER_INTERNAL] = encodedMetadata } + if (this.region) { + urlPath = `/region:${this.region}${urlPath}` + } + const url = new URL(urlPath, consistency === 'strong' ? this.uncachedEdgeURL : this.edgeURL) for (const key in parameters) { @@ -114,6 +124,10 @@ export class Client { url.searchParams.set(key, parameters[key]) } + if (this.region) { + url.searchParams.set('region', this.region) + } + // If there is no store name, we're listing stores. If there's no key, // we're listing blobs. Both operations are implemented directly in the // Netlify API. @@ -205,9 +219,9 @@ export class Client { * @param contextOverride Context to be used instead of the environment object */ export const getClientOptions = ( - options: Partial, + options: Partial, contextOverride?: EnvironmentContext, -): ClientOptions => { +): InternalClientOptions => { const context = contextOverride ?? getEnvironmentContext() const siteID = context.siteID ?? options.siteID const token = context.token ?? options.token @@ -216,11 +230,12 @@ export const getClientOptions = ( throw new MissingBlobsEnvironmentError(['siteID', 'token']) } - const clientOptions = { + const clientOptions: InternalClientOptions = { apiURL: context.apiURL ?? options.apiURL, consistency: options.consistency, edgeURL: context.edgeURL ?? options.edgeURL, fetch: options.fetch, + region: options.region, siteID, token, uncachedEdgeURL: context.uncachedEdgeURL ?? options.uncachedEdgeURL, diff --git a/src/environment.ts b/src/environment.ts index 0522f53..8296004 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -58,6 +58,7 @@ export interface EnvironmentContext { apiURL?: string deployID?: string edgeURL?: string + primaryRegion?: string siteID?: string token?: string uncachedEdgeURL?: string diff --git a/src/main.test.ts b/src/main.test.ts index 8e4a0ec..9d67bbc 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1522,3 +1522,181 @@ describe(`getStore`, () => { ) }) }) + +describe('Region configuration', () => { + describe('With `experimentalRegion: "auto"`', () => { + test('The client sends a `region=auto` parameter to API calls', async () => { + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=auto`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=auto`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + globalThis.fetch = mockStore.fetch + + const deployStore = getDeployStore({ deployID, siteID, token: apiToken, experimentalRegion: 'auto' }) + + const string = await deployStore.get(key) + expect(string).toBe(value) + + const stream = await deployStore.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + expect(mockStore.fulfilled).toBeTruthy() + }) + + test('Throws when used with `edgeURL`', async () => { + const mockRegion = 'us-east-2' + const mockToken = 'some-token' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + + globalThis.fetch = mockStore.fetch + + expect(() => + getDeployStore({ deployID, edgeURL, siteID, token: mockToken, experimentalRegion: 'auto' }), + ).toThrowError() + expect(mockStore.fulfilled).toBeFalsy() + }) + }) + + describe('With `experimentalRegion: "context"`', () => { + test('Adds a `region` parameter to API calls with the value set in the context', async () => { + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=us-east-1`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=us-east-1`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + const context = { + deployID, + siteID, + primaryRegion: 'us-east-1', + token: apiToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + globalThis.fetch = mockStore.fetch + + const deployStore = getDeployStore({ experimentalRegion: 'context' }) + + const string = await deployStore.get(key) + expect(string).toBe(value) + + const stream = await deployStore.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + expect(mockStore.fulfilled).toBeTruthy() + }) + + test('Adds a `region:` segment to the edge URL path with the value set in the context', async () => { + const mockRegion = 'us-east-2' + const mockToken = 'some-token' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + + globalThis.fetch = mockStore.fetch + + const context = { + deployID, + edgeURL, + primaryRegion: mockRegion, + siteID, + token: mockToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + globalThis.fetch = mockStore.fetch + + const deployStore = getDeployStore({ experimentalRegion: 'context' }) + + const string = await deployStore.get(key) + expect(string).toBe(value) + + const stream = await deployStore.get(key, { type: 'stream' }) + expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value) + + expect(mockStore.fulfilled).toBeTruthy() + }) + + test('Throws an error when there is no region set in the context', async () => { + const mockRegion = 'us-east-2' + const mockToken = 'some-token' + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + .get({ + headers: { authorization: `Bearer ${mockToken}` }, + response: new Response(value), + url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`, + }) + + globalThis.fetch = mockStore.fetch + + const context = { + deployID, + edgeURL, + siteID, + token: mockToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + globalThis.fetch = mockStore.fetch + + expect(() => getDeployStore({ experimentalRegion: 'context' })).toThrowError() + expect(mockStore.fulfilled).toBeFalsy() + }) + }) +}) diff --git a/src/store_factory.ts b/src/store_factory.ts index 57db708..27dcb08 100644 --- a/src/store_factory.ts +++ b/src/store_factory.ts @@ -2,8 +2,16 @@ import { Client, ClientOptions, getClientOptions } from './client.ts' import { getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts' import { Store } from './store.ts' +type ExperimentalRegion = + // Sets "region=auto", which is supported by our API in deploy stores. + | 'auto' + + // Loads the region from the environment context and throws if not found. + | 'context' + interface GetDeployStoreOptions extends Partial { deployID?: string + experimentalRegion?: ExperimentalRegion } /** @@ -18,6 +26,25 @@ export const getDeployStore = (options: GetDeployStoreOptions = {}): Store => { } const clientOptions = getClientOptions(options, context) + + if (options.experimentalRegion === 'context') { + if (!context.primaryRegion) { + throw new Error( + 'The Netlify Blobs client was initialized with `experimentalRegion: "context"` but there is no region configured in the environment', + ) + } + + clientOptions.region = context.primaryRegion + } else if (options.experimentalRegion === 'auto') { + if (clientOptions.edgeURL) { + throw new Error( + 'The Netlify Blobs client was initialized with `experimentalRegion: "auto"` which is not compatible with the `edgeURL` property; consider using `apiURL` instead', + ) + } + + clientOptions.region = options.experimentalRegion + } + const client = new Client(clientOptions) return new Store({ client, deployID })