Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add experimental support for region selection #158

Merged
merged 1 commit into from
Apr 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading