diff --git a/src/client.ts b/src/client.ts index a8021af..e7dc6ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -23,16 +23,22 @@ export interface ClientOptions { export class Client { private apiURL?: string private edgeURL?: string - private fetch?: Fetcher + private fetch: Fetcher private siteID: string private token: string constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions) { this.apiURL = apiURL this.edgeURL = edgeURL - this.fetch = fetch + this.fetch = fetch ?? globalThis.fetch this.siteID = siteID this.token = token + + if (!this.fetch) { + throw new Error( + 'Netlify Blobs could not find a `fetch` client in the global scope. You can either update your runtime to a version that includes `fetch` (like Node.js 18.0.0 or above), or you can supply your own implementation using the `fetch` property.', + ) + } } private async getFinalRequest(storeName: string, key: string, method: string, metadata?: Metadata) { @@ -63,8 +69,7 @@ export class Client { apiHeaders[METADATA_HEADER_EXTERNAL] = encodedMetadata } - const fetch = this.fetch ?? globalThis.fetch - const res = await fetch(apiURL, { headers: apiHeaders, method }) + const res = await this.fetch(apiURL, { headers: apiHeaders, method }) if (res.status !== 200) { throw new Error(`${method} operation has failed: API returned a ${res.status} response`) @@ -102,8 +107,7 @@ export class Client { options.duplex = 'half' } - const fetch = this.fetch ?? globalThis.fetch - const res = await fetchAndRetry(fetch, url, options) + const res = await fetchAndRetry(this.fetch, url, options) if (res.status === 404 && method === HTTPMethod.GET) { return null diff --git a/src/main.test.ts b/src/main.test.ts index a0a714b..31bb1b9 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1120,4 +1120,71 @@ describe(`getStore`, () => { 'The `getStore` method requires the name of the store as a string or as the `name` property of an options object', ) }) + + test('Throws when the name of the store starts with the `deploy:` prefix', async () => { + const { fetch } = new MockFetch() + + globalThis.fetch = fetch + + expect(() => + getStore({ + name: 'deploy:foo', + token: apiToken, + siteID, + }), + ).toThrowError('Store name cannot start with the string `deploy:`, which is a reserved namespace') + + const context = { + siteID, + token: apiToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + expect(() => getStore('deploy:foo')).toThrowError( + 'Store name cannot start with the string `deploy:`, which is a reserved namespace', + ) + }) + + test('Throws when there is no `fetch` implementation available', async () => { + // @ts-expect-error Assigning a value that doesn't match the type. + globalThis.fetch = undefined + + const context = { + siteID, + token: apiToken, + } + + env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64') + + expect(() => getStore('production')).toThrowError( + 'Netlify Blobs could not find a `fetch` client in the global scope. You can either update your runtime to a version that includes `fetch` (like Node.js 18.0.0 or above), or you can supply your own implementation using the `fetch` property.', + ) + }) + + test('URL-encodes the store name', async () => { + const mockStore = new MockFetch() + .get({ + headers: { authorization: `Bearer ${apiToken}` }, + response: new Response(JSON.stringify({ url: signedURL })), + url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=%2Fwhat%3F`, + }) + .get({ + response: new Response(value), + url: signedURL, + }) + + globalThis.fetch = mockStore.fetch + + const blobs = getStore({ + name: '/what?', + token: apiToken, + siteID, + }) + + const string = await blobs.get(key) + expect(string).toBe(value) + + expect(mockStore.fulfilled).toBeTruthy() + }) }) diff --git a/src/store.ts b/src/store.ts index bf3be46..bd94d9d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -33,7 +33,14 @@ export class Store { constructor(options: StoreOptions) { this.client = options.client - this.name = 'deployID' in options ? `deploy:${options.deployID}` : options.name + + if ('deployID' in options) { + this.name = `deploy:${encodeURIComponent(options.deployID)}` + } else if (options?.name.startsWith('deploy:')) { + throw new Error('Store name cannot start with the string `deploy:`, which is a reserved namespace') + } else { + this.name = encodeURIComponent(options.name) + } } async delete(key: string) {