Skip to content

feat!: add region parameter #183

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

Merged
merged 3 commits into from
Jul 3, 2024
Merged
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
5 changes: 5 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BlobsConsistencyError, ConsistencyMode } from './consistency.ts'
import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts'
import { encodeMetadata, Metadata, METADATA_HEADER_EXTERNAL, METADATA_HEADER_INTERNAL } from './metadata.ts'
import { InvalidBlobsRegionError, isValidRegion } from './region.ts'
import { fetchAndRetry } from './retry.ts'
import { BlobInput, Fetcher, HTTPMethod } from './types.ts'
import { BlobsInternalError } from './util.ts'
@@ -231,6 +232,10 @@ export const getClientOptions = (
throw new MissingBlobsEnvironmentError(['siteID', 'token'])
}

if (options.region !== undefined && !isValidRegion(options.region)) {
throw new InvalidBlobsRegionError(options.region)
}

const clientOptions: InternalClientOptions = {
apiURL: context.apiURL ?? options.apiURL,
consistency: options.consistency,
8 changes: 5 additions & 3 deletions src/consistency.test.ts
Original file line number Diff line number Diff line change
@@ -152,6 +152,7 @@ describe('Consistency configuration', () => {
cool: true,
functions: ['edge', 'serverless'],
}
const mockRegion = 'us-east-1'
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
@@ -160,17 +161,17 @@ describe('Consistency configuration', () => {
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/deploy:${deployID}/${key}`,
url: `${uncachedEdgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${uncachedEdgeURL}/${siteID}/deploy:${deployID}/${key}`,
url: `${uncachedEdgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${uncachedEdgeURL}/${siteID}/deploy:${deployID}/${key}`,
url: `${uncachedEdgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`,
})

globalThis.fetch = mockStore.fetch
@@ -179,6 +180,7 @@ describe('Consistency configuration', () => {
consistency: 'strong',
edgeURL,
deployID,
region: mockRegion,
token: edgeToken,
siteID,
uncachedEdgeURL,
189 changes: 161 additions & 28 deletions src/main.test.ts
Original file line number Diff line number Diff line change
@@ -1316,23 +1316,25 @@ describe('Deploy scope', () => {

test('Returns a deploy-scoped store if the `getDeployStore` method is called and the environment context is present', async () => {
const mockToken = 'some-token'
const mockRegion = 'us-east-2'
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${mockToken}` },
response: new Response(value),
url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`,
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`,
})
.get({
headers: { authorization: `Bearer ${mockToken}` },
response: new Response(value),
url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`,
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`,
})

globalThis.fetch = mockStore.fetch

const context = {
deployID,
edgeURL,
primaryRegion: mockRegion,
siteID,
token: mockToken,
}
@@ -1355,7 +1357,7 @@ describe('Deploy scope', () => {
.get({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: signedURL })),
url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}`,
url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=auto`,
})
.get({
response: new Response(value),
@@ -1364,7 +1366,7 @@ describe('Deploy scope', () => {
.get({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: signedURL })),
url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}`,
url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=auto`,
})
.get({
response: new Response(value),
@@ -1385,25 +1387,27 @@ describe('Deploy scope', () => {
})

test('Returns a named deploy-scoped store if `getDeployStore` receives a string parameter', async () => {
const mockRegion = 'us-east-1'
const mockToken = 'some-token'
const mockStoreName = 'my-store'
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${mockToken}` },
response: new Response(value),
url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
})
.get({
headers: { authorization: `Bearer ${mockToken}` },
response: new Response(value),
url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
})

globalThis.fetch = mockStore.fetch

const context = {
deployID,
edgeURL,
primaryRegion: mockRegion,
siteID,
token: mockToken,
}
@@ -1422,25 +1426,27 @@ describe('Deploy scope', () => {
})

test('Returns a named deploy-scoped store if `getDeployStore` receives an object with a `name` property', async () => {
const mockRegion = 'us-east-1'
const mockToken = 'some-token'
const mockStoreName = 'my-store'
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${mockToken}` },
response: new Response(value),
url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
})
.get({
headers: { authorization: `Bearer ${mockToken}` },
response: new Response(value),
url: `${edgeURL}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}:${mockStoreName}/${key}`,
})

globalThis.fetch = mockStore.fetch

const context = {
deployID,
edgeURL,
primaryRegion: mockRegion,
siteID,
token: mockToken,
}
@@ -1459,13 +1465,14 @@ describe('Deploy scope', () => {
})

test('Throws if the deploy ID fails validation', async () => {
const mockRegion = 'us-east-2'
const mockToken = 'some-token'
const mockStore = new MockFetch()
const longDeployID = 'd'.repeat(80)

globalThis.fetch = mockStore.fetch

expect(() => getDeployStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError(
expect(() => getDeployStore({ deployID: 'deploy/ID', siteID, region: mockRegion, token: apiToken })).toThrowError(
`'deploy/ID' is not a valid Netlify deploy ID`,
)
expect(() => getStore({ deployID: 'deploy/ID', siteID, token: apiToken })).toThrowError(
@@ -1478,6 +1485,7 @@ describe('Deploy scope', () => {
const context = {
deployID: 'uhoh!',
edgeURL,
primaryRegion: mockRegion,
siteID,
token: mockToken,
}
@@ -1601,8 +1609,8 @@ describe(`getStore`, () => {
})
})

describe('Region configuration', () => {
describe('With `experimentalRegion: "auto"`', () => {
describe('Region configuration in deploy-scoped stores', () => {
describe('Without a `region` option', () => {
test('The client sends a `region=auto` parameter to API calls', async () => {
const mockStore = new MockFetch()
.get({
@@ -1626,7 +1634,7 @@ describe('Region configuration', () => {

globalThis.fetch = mockStore.fetch

const deployStore = getDeployStore({ deployID, siteID, token: apiToken, experimentalRegion: 'auto' })
const deployStore = getDeployStore({ deployID, siteID, token: apiToken })

const string = await deployStore.get(key)
expect(string).toBe(value)
@@ -1637,7 +1645,7 @@ describe('Region configuration', () => {
expect(mockStore.fulfilled).toBeTruthy()
})

test('Throws when used with `edgeURL`', async () => {
test('The client sends the region configured in the context to edge calls', async () => {
const mockRegion = 'us-east-2'
const mockToken = 'some-token'
const mockStore = new MockFetch()
@@ -1652,22 +1660,59 @@ describe('Region configuration', () => {
url: `${edgeURL}/region:${mockRegion}/${siteID}/deploy:${deployID}/${key}`,
})

const context = {
edgeURL,
deployID,
siteID,
primaryRegion: mockRegion,
token: mockToken,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

globalThis.fetch = mockStore.fetch

expect(() =>
getDeployStore({ deployID, edgeURL, siteID, token: mockToken, experimentalRegion: 'auto' }),
).toThrowError()
const deployStore = getDeployStore()

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 if using the edge URL and no region is configured 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

expect(() => getDeployStore({ deployID, edgeURL, siteID, token: mockToken })).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 () => {
describe('With a `region` option', () => {
test('The client sends that region to API calls', async () => {
const mockRegion = 'us-east-1'
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`,
url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`,
})
.get({
response: new Response(value),
@@ -1676,7 +1721,7 @@ describe('Region configuration', () => {
.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`,
url: `https://api.netlify.com/api/v1/blobs/${siteID}/deploy:${deployID}/${key}?region=${mockRegion}`,
})
.get({
response: new Response(value),
@@ -1686,15 +1731,14 @@ describe('Region configuration', () => {
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 deployStore = getDeployStore({ region: mockRegion })

const string = await deployStore.get(key)
expect(string).toBe(value)
@@ -1705,7 +1749,89 @@ describe('Region configuration', () => {
expect(mockStore.fulfilled).toBeTruthy()
})

test('Adds a `region:` segment to the edge URL path with the value set in the context', async () => {
test('The client sends that region to API calls, even if a different region is present in the context', async () => {
const mockRegion = 'us-east-1'
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=${mockRegion}`,
})
.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=${mockRegion}`,
})
.get({
response: new Response(value),
url: signedURL,
})

const context = {
deployID,
siteID,
primaryRegion: 'us-east-2',
token: apiToken,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

globalThis.fetch = mockStore.fetch

const deployStore = getDeployStore({ region: mockRegion })

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('The client throws an error if the region supplied is not supported', async () => {
const mockRegion = 'us-east-1'
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=${mockRegion}`,
})
.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=${mockRegion}`,
})
.get({
response: new Response(value),
url: signedURL,
})

const context = {
deployID,
siteID,
primaryRegion: 'us-east-2',
token: apiToken,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

globalThis.fetch = mockStore.fetch

// @ts-expect-error Knowingly supplying an invalid value to `region`.
expect(() => getDeployStore({ deployID, edgeURL, siteID, region: 'eu-west-1' })).toThrowError()
expect(mockStore.fulfilled).toBeFalsy()
})

test('The client sends that region to edge calls', async () => {
const mockRegion = 'us-east-2'
const mockToken = 'some-token'
const mockStore = new MockFetch()
@@ -1725,7 +1851,6 @@ describe('Region configuration', () => {
const context = {
deployID,
edgeURL,
primaryRegion: mockRegion,
siteID,
token: mockToken,
}
@@ -1734,7 +1859,7 @@ describe('Region configuration', () => {

globalThis.fetch = mockStore.fetch

const deployStore = getDeployStore({ experimentalRegion: 'context' })
const deployStore = getDeployStore({ region: mockRegion })

const string = await deployStore.get(key)
expect(string).toBe(value)
@@ -1745,7 +1870,7 @@ describe('Region configuration', () => {
expect(mockStore.fulfilled).toBeTruthy()
})

test('Throws an error when there is no region set in the context', async () => {
test('The client sends that region to edge calls, even if a different region is set in the context', async () => {
const mockRegion = 'us-east-2'
const mockToken = 'some-token'
const mockStore = new MockFetch()
@@ -1765,6 +1890,7 @@ describe('Region configuration', () => {
const context = {
deployID,
edgeURL,
primaryRegion: 'us-east-1',
siteID,
token: mockToken,
}
@@ -1773,8 +1899,15 @@ describe('Region configuration', () => {

globalThis.fetch = mockStore.fetch

expect(() => getDeployStore({ experimentalRegion: 'context' })).toThrowError()
expect(mockStore.fulfilled).toBeFalsy()
const deployStore = getDeployStore({ region: mockRegion })

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()
})
})
})
20 changes: 20 additions & 0 deletions src/region.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const REGION_AUTO = 'auto'

const regions = {
'us-east-1': true,
'us-east-2': true,
}

export type Region = keyof typeof regions

export const isValidRegion = (input: string): input is Region => Object.keys(regions).includes(input)

export class InvalidBlobsRegionError extends Error {
constructor(region: string) {
super(
`${region} is not a supported Netlify Blobs region. Supported values are: ${Object.keys(regions).join(', ')}.`,
)

this.name = 'InvalidBlobsRegionError'
}
}
6 changes: 4 additions & 2 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -306,6 +306,7 @@ test('Works with a deploy-scoped store', async () => {
const store = getDeployStore({
deployID,
edgeURL: `http://localhost:${port}`,
region: 'us-east-1',
token,
siteID,
})
@@ -356,6 +357,7 @@ test('Lists site stores', async () => {
const store3 = getDeployStore({
deployID: '655f77a1b48f470008e5879a',
edgeURL: `http://localhost:${port}`,
region: 'us-east-1',
token,
siteID,
})
@@ -431,7 +433,7 @@ test('Returns a signed URL or the blob directly based on the request parameters'
await fs.rm(directory.path, { force: true, recursive: true })
})

test('Accepts stores with `experimentalRegion`', async () => {
test('Accepts deploy-scoped stores with the region defined in the context', async () => {
const deployID = '655f77a1b48f470008e5879a'
const directory = await tmp.dir()
const server = new BlobsServer({
@@ -450,7 +452,7 @@ test('Accepts stores with `experimentalRegion`', async () => {

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

const store = getDeployStore({ experimentalRegion: 'context' })
const store = getDeployStore()
const key = 'my-key'
const value = 'hello from a deploy store'

41 changes: 18 additions & 23 deletions src/store_factory.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { Client, ClientOptions, getClientOptions } from './client.ts'
import { getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts'
import { Region, REGION_AUTO } from './region.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
name?: string
experimentalRegion?: ExperimentalRegion
region?: Region
}

/**
@@ -29,22 +23,23 @@ export const getDeployStore = (input: GetDeployStoreOptions | string = {}): Stor

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',
)
if (!clientOptions.region) {
// If a region hasn't been supplied and we're dealing with an edge request,
// use the region from the context if one is defined, otherwise throw.
if (clientOptions.edgeURL || clientOptions.uncachedEdgeURL) {
// eslint-disable-next-line max-depth
if (!context.primaryRegion) {
throw new Error(
'When accessing a deploy store, the Netlify Blobs client needs to be configured with a region, and one was not found in the environment. To manually set the region, set the `region` property in the `getDeployStore` options.',
)
}

clientOptions.region = context.primaryRegion
} else {
// For API requests, we can use `auto` and let the API choose the right
// region.
clientOptions.region = REGION_AUTO
}

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)