Skip to content

Commit

Permalink
feat: add experimental support for region selection (#158)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoboucas authored Apr 1, 2024
1 parent c599473 commit 6734751
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 4 deletions.
23 changes: 19 additions & 4 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export interface ClientOptions {
uncachedEdgeURL?: string
}

interface InternalClientOptions extends ClientOptions {
region?: string
}

interface GetFinalRequestOptions {
consistency?: ConsistencyMode
key: string | undefined
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -205,9 +219,9 @@ export class Client {
* @param contextOverride Context to be used instead of the environment object
*/
export const getClientOptions = (
options: Partial<ClientOptions>,
options: Partial<InternalClientOptions>,
contextOverride?: EnvironmentContext,
): ClientOptions => {
): InternalClientOptions => {
const context = contextOverride ?? getEnvironmentContext()
const siteID = context.siteID ?? options.siteID
const token = context.token ?? options.token
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface EnvironmentContext {
apiURL?: string
deployID?: string
edgeURL?: string
primaryRegion?: string
siteID?: string
token?: string
uncachedEdgeURL?: string
Expand Down
178 changes: 178 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
})
27 changes: 27 additions & 0 deletions src/store_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientOptions> {
deployID?: string
experimentalRegion?: ExperimentalRegion
}

/**
Expand All @@ -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 })
Expand Down

0 comments on commit 6734751

Please sign in to comment.