Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 9403217

Browse files
committedOct 18, 2023
feat: add support for metadata
1 parent e2bdfff commit 9403217

File tree

6 files changed

+349
-100
lines changed

6 files changed

+349
-100
lines changed
 

‎README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ console.log(await store.get('my-key'))
169169

170170
## Store API reference
171171

172-
### `get(key: string, { type: string }): Promise<any>`
172+
### `get(key: string, { type?: string }): Promise<any>`
173173

174174
Retrieves an object with the given key.
175175

@@ -191,6 +191,29 @@ const entry = await blobs.get('some-key', { type: 'json' })
191191
console.log(entry)
192192
```
193193

194+
### `getWithMetadata(key: string, { type?: string }): Promise<{ data: any, etag: string, metadata: object }>`
195+
196+
Retrieves an object with the given key, the [ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
197+
for the entry, and any metadata that has been stored with the entry.
198+
199+
Depending on the most convenient format for you to access the value, you may choose to supply a `type` property as a
200+
second parameter, with one of the following values:
201+
202+
- `arrayBuffer`: Returns the entry as an
203+
[`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer)
204+
- `blob`: Returns the entry as a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
205+
- `json`: Parses the entry as JSON and returns the resulting object
206+
- `stream`: Returns the entry as a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
207+
- `text` (default): Returns the entry as a string of plain text
208+
209+
If an object with the given key is not found, `null` is returned.
210+
211+
```javascript
212+
const blob = await blobs.getWithMetadata('some-key', { type: 'json' })
213+
214+
console.log(blob.data, blob.etag, blob.metadata)
215+
```
216+
194217
### `set(key: string, value: ArrayBuffer | Blob | ReadableStream | string): Promise<void>`
195218

196219
Creates an object with the given key and value.

‎src/client.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts'
2+
import { encodeMetadata, Metadata, METADATA_HEADER_EXTERNAL } from './metadata.ts'
23
import { fetchAndRetry } from './retry.ts'
34
import { BlobInput, Fetcher, HTTPMethod } from './types.ts'
45

56
interface MakeStoreRequestOptions {
67
body?: BlobInput | null
78
headers?: Record<string, string>
89
key: string
10+
metadata?: Metadata
911
method: HTTPMethod
1012
storeName: string
1113
}
@@ -33,21 +35,32 @@ export class Client {
3335
this.token = token
3436
}
3537

36-
private async getFinalRequest(storeName: string, key: string, method: string) {
38+
private async getFinalRequest(storeName: string, key: string, method: string, metadata?: Metadata) {
3739
const encodedKey = encodeURIComponent(key)
3840

3941
if (this.edgeURL) {
42+
const headers: Record<string, string> = {
43+
authorization: `Bearer ${this.token}`,
44+
}
45+
46+
if (metadata) {
47+
headers[METADATA_HEADER_EXTERNAL] = encodeMetadata(metadata)
48+
}
49+
4050
return {
41-
headers: {
42-
authorization: `Bearer ${this.token}`,
43-
},
51+
headers,
4452
url: `${this.edgeURL}/${this.siteID}/${storeName}/${encodedKey}`,
4553
}
4654
}
4755

48-
const apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
56+
let apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
4957
this.siteID
5058
}/blobs/${encodedKey}?context=${storeName}`
59+
60+
if (metadata) {
61+
apiURL += `&metadata=${encodeMetadata(metadata)}`
62+
}
63+
5164
const headers = { authorization: `Bearer ${this.token}` }
5265
const fetch = this.fetch ?? globalThis.fetch
5366
const res = await fetch(apiURL, { headers, method })
@@ -63,8 +76,8 @@ export class Client {
6376
}
6477
}
6578

66-
async makeRequest({ body, headers: extraHeaders, key, method, storeName }: MakeStoreRequestOptions) {
67-
const { headers: baseHeaders = {}, url } = await this.getFinalRequest(storeName, key, method)
79+
async makeRequest({ body, headers: extraHeaders, key, metadata, method, storeName }: MakeStoreRequestOptions) {
80+
const { headers: baseHeaders = {}, url } = await this.getFinalRequest(storeName, key, method, metadata)
6881
const headers: Record<string, string> = {
6982
...baseHeaders,
7083
...extraHeaders,

‎src/main.test.ts

Lines changed: 186 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import semver from 'semver'
55
import { describe, test, expect, beforeAll, afterEach } from 'vitest'
66

77
import { MockFetch } from '../test/mock_fetch.js'
8-
import { streamToString } from '../test/util.js'
8+
import { base64Encode, streamToString } from '../test/util.js'
99

1010
import { MissingBlobsEnvironmentError } from './environment.js'
1111
import { getDeployStore, getStore } from './main.js'
@@ -164,34 +164,6 @@ describe('get', () => {
164164

165165
expect(mockStore.fulfilled).toBeTruthy()
166166
})
167-
168-
test('Returns `null` when the blob entry contains an expiry date in the past', async () => {
169-
const mockStore = new MockFetch()
170-
.get({
171-
headers: { authorization: `Bearer ${apiToken}` },
172-
response: new Response(JSON.stringify({ url: signedURL })),
173-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
174-
})
175-
.get({
176-
response: new Response(value, {
177-
headers: {
178-
'x-nf-expires-at': (Date.now() - 1000).toString(),
179-
},
180-
}),
181-
url: signedURL,
182-
})
183-
184-
globalThis.fetch = mockStore.fetch
185-
186-
const blobs = getStore({
187-
name: 'production',
188-
token: apiToken,
189-
siteID,
190-
})
191-
192-
expect(await blobs.get(key)).toBeNull()
193-
expect(mockStore.fulfilled).toBeTruthy()
194-
})
195167
})
196168

197169
describe('With edge credentials', () => {
@@ -318,6 +290,162 @@ describe('get', () => {
318290
})
319291
})
320292

293+
describe('getWithMetadata', () => {
294+
describe('With API credentials', () => {
295+
test('Reads from the blob store and returns the etag and the metadata object', async () => {
296+
const mockMetadata = {
297+
name: 'Netlify',
298+
cool: true,
299+
functions: ['edge', 'serverless'],
300+
}
301+
const responseHeaders = {
302+
etag: '123456789',
303+
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
304+
}
305+
const mockStore = new MockFetch()
306+
.get({
307+
headers: { authorization: `Bearer ${apiToken}` },
308+
response: new Response(JSON.stringify({ url: signedURL })),
309+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
310+
})
311+
.get({
312+
response: new Response(value, { headers: responseHeaders }),
313+
url: signedURL,
314+
})
315+
.get({
316+
headers: { authorization: `Bearer ${apiToken}` },
317+
response: new Response(JSON.stringify({ url: signedURL })),
318+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
319+
})
320+
.get({
321+
response: new Response(value, { headers: responseHeaders }),
322+
url: signedURL,
323+
})
324+
325+
globalThis.fetch = mockStore.fetch
326+
327+
const blobs = getStore({
328+
name: 'production',
329+
token: apiToken,
330+
siteID,
331+
})
332+
333+
const entry1 = await blobs.getWithMetadata(key)
334+
expect(entry1.data).toBe(value)
335+
expect(entry1.etag).toBe(responseHeaders.etag)
336+
expect(entry1.metadata).toEqual(mockMetadata)
337+
338+
const entry2 = await blobs.getWithMetadata(key, { type: 'stream' })
339+
expect(await streamToString(entry2.data as unknown as NodeJS.ReadableStream)).toBe(value)
340+
expect(entry2.etag).toBe(responseHeaders.etag)
341+
expect(entry2.metadata).toEqual(mockMetadata)
342+
343+
expect(mockStore.fulfilled).toBeTruthy()
344+
})
345+
346+
test('Returns `null` when the pre-signed URL returns a 404', async () => {
347+
const mockStore = new MockFetch()
348+
.get({
349+
headers: { authorization: `Bearer ${apiToken}` },
350+
response: new Response(JSON.stringify({ url: signedURL })),
351+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
352+
})
353+
.get({
354+
response: new Response('Something went wrong', { status: 404 }),
355+
url: signedURL,
356+
})
357+
358+
globalThis.fetch = mockStore.fetch
359+
360+
const blobs = getStore({
361+
name: 'production',
362+
token: apiToken,
363+
siteID,
364+
})
365+
366+
expect(await blobs.getWithMetadata(key)).toBeNull()
367+
expect(mockStore.fulfilled).toBeTruthy()
368+
})
369+
370+
test('Throws when the metadata object cannot be parsed', async () => {
371+
const responseHeaders = {
372+
etag: '123456789',
373+
'x-amz-meta-user': `b64;${base64Encode(`{"name": "Netlify", "cool`)}`,
374+
}
375+
const mockStore = new MockFetch()
376+
.get({
377+
headers: { authorization: `Bearer ${apiToken}` },
378+
response: new Response(JSON.stringify({ url: signedURL })),
379+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
380+
})
381+
.get({
382+
response: new Response(value, { headers: responseHeaders }),
383+
url: signedURL,
384+
})
385+
386+
globalThis.fetch = mockStore.fetch
387+
388+
const blobs = getStore({
389+
name: 'production',
390+
token: apiToken,
391+
siteID,
392+
})
393+
394+
await expect(async () => await blobs.getWithMetadata(key)).rejects.toThrowError(
395+
'An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client.',
396+
)
397+
398+
expect(mockStore.fulfilled).toBeTruthy()
399+
})
400+
})
401+
402+
describe('With edge credentials', () => {
403+
test('Reads from the blob store and returns the etag and the metadata object', async () => {
404+
const mockMetadata = {
405+
name: 'Netlify',
406+
cool: true,
407+
functions: ['edge', 'serverless'],
408+
}
409+
const responseHeaders = {
410+
etag: '123456789',
411+
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
412+
}
413+
const mockStore = new MockFetch()
414+
.get({
415+
headers: { authorization: `Bearer ${edgeToken}` },
416+
response: new Response(value, { headers: responseHeaders }),
417+
url: `${edgeURL}/${siteID}/production/${key}`,
418+
})
419+
.get({
420+
headers: { authorization: `Bearer ${edgeToken}` },
421+
response: new Response(value, { headers: responseHeaders }),
422+
url: `${edgeURL}/${siteID}/production/${key}`,
423+
})
424+
425+
globalThis.fetch = mockStore.fetch
426+
427+
const blobs = getStore({
428+
edgeURL,
429+
name: 'production',
430+
token: edgeToken,
431+
siteID,
432+
})
433+
434+
const entry1 = await blobs.getWithMetadata(key)
435+
expect(entry1.data).toBe(value)
436+
expect(entry1.etag).toBe(responseHeaders.etag)
437+
expect(entry1.metadata).toEqual(mockMetadata)
438+
439+
const entry2 = await blobs.getWithMetadata(key, { type: 'stream' })
440+
expect(await streamToString(entry2.data as unknown as NodeJS.ReadableStream)).toBe(value)
441+
expect(entry2.etag).toBe(responseHeaders.etag)
442+
expect(entry2.metadata).toEqual(mockMetadata)
443+
444+
expect(mockStore.fulfilled).toBeTruthy()
445+
})
446+
})
447+
})
448+
321449
describe('set', () => {
322450
describe('With API credentials', () => {
323451
test('Writes to the blob store', async () => {
@@ -361,19 +489,23 @@ describe('set', () => {
361489
expect(mockStore.fulfilled).toBeTruthy()
362490
})
363491

364-
test('Accepts an `expiration` parameter', async () => {
365-
const expiration = new Date(Date.now() + 15_000)
492+
test('Accepts a `metadata` parameter', async () => {
493+
const metadata = {
494+
name: 'Netlify',
495+
cool: true,
496+
functions: ['edge', 'serverless'],
497+
}
498+
const encodedMetadata = `b64;${Buffer.from(JSON.stringify(metadata)).toString('base64')}`
366499
const mockStore = new MockFetch()
367500
.put({
368501
headers: { authorization: `Bearer ${apiToken}` },
369502
response: new Response(JSON.stringify({ url: signedURL })),
370-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
503+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production&metadata=${encodedMetadata}`,
371504
})
372505
.put({
373506
body: value,
374507
headers: {
375508
'cache-control': 'max-age=0, stale-while-revalidate=60',
376-
'x-nf-expires-at': expiration.getTime().toString(),
377509
},
378510
response: new Response(null),
379511
url: signedURL,
@@ -387,7 +519,7 @@ describe('set', () => {
387519
siteID,
388520
})
389521

390-
await blobs.set(key, value, { expiration })
522+
await blobs.set(key, value, { metadata })
391523

392524
expect(mockStore.fulfilled).toBeTruthy()
393525
})
@@ -620,33 +752,34 @@ describe('setJSON', () => {
620752
expect(mockStore.fulfilled).toBeTruthy()
621753
})
622754

623-
test('Accepts an `expiration` parameter', async () => {
624-
const expiration = new Date(Date.now() + 15_000)
625-
const mockStore = new MockFetch()
626-
.put({
627-
headers: { authorization: `Bearer ${apiToken}` },
628-
response: new Response(JSON.stringify({ url: signedURL })),
629-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
630-
})
631-
.put({
632-
body: JSON.stringify({ value }),
633-
headers: {
634-
'cache-control': 'max-age=0, stale-while-revalidate=60',
635-
'x-nf-expires-at': expiration.getTime().toString(),
636-
},
637-
response: new Response(null),
638-
url: signedURL,
639-
})
755+
test('Accepts a `metadata` parameter', async () => {
756+
const metadata = {
757+
name: 'Netlify',
758+
cool: true,
759+
functions: ['edge', 'serverless'],
760+
}
761+
const encodedMetadata = `b64;${Buffer.from(JSON.stringify(metadata)).toString('base64')}`
762+
const mockStore = new MockFetch().put({
763+
body: JSON.stringify({ value }),
764+
headers: {
765+
authorization: `Bearer ${edgeToken}`,
766+
'cache-control': 'max-age=0, stale-while-revalidate=60',
767+
'netlify-blobs-metadata': encodedMetadata,
768+
},
769+
response: new Response(null),
770+
url: `${edgeURL}/${siteID}/production/${key}`,
771+
})
640772

641773
globalThis.fetch = mockStore.fetch
642774

643775
const blobs = getStore({
776+
edgeURL,
644777
name: 'production',
645-
token: apiToken,
778+
token: edgeToken,
646779
siteID,
647780
})
648781

649-
await blobs.setJSON(key, { value }, { expiration })
782+
await blobs.setJSON(key, { value }, { metadata })
650783

651784
expect(mockStore.fulfilled).toBeTruthy()
652785
})

‎src/metadata.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Buffer } from 'node:buffer'
2+
3+
export type Metadata = Record<string, unknown>
4+
5+
const BASE64_PREFIX = 'b64;'
6+
export const METADATA_HEADER_INTERNAL = 'x-amz-meta-user'
7+
export const METADATA_HEADER_EXTERNAL = 'netlify-blobs-metadata'
8+
const METADATA_MAX_SIZE = 2 * 1024
9+
10+
export const encodeMetadata = (metadata: Metadata) => {
11+
const encodedObject = Buffer.from(JSON.stringify(metadata)).toString('base64')
12+
const payload = `b64;${encodedObject}`
13+
14+
if (METADATA_HEADER_EXTERNAL.length + payload.length > METADATA_MAX_SIZE) {
15+
throw new Error('Metadata object exceeds the maximum size')
16+
}
17+
18+
return payload
19+
}
20+
21+
export const decodeMetadata = (headers?: Headers): Metadata => {
22+
if (!headers) {
23+
return {}
24+
}
25+
26+
const metadataHeader = headers.get(METADATA_HEADER_INTERNAL)
27+
28+
if (!metadataHeader || !metadataHeader.startsWith(BASE64_PREFIX)) {
29+
return {}
30+
}
31+
32+
const encodedData = metadataHeader.slice(BASE64_PREFIX.length)
33+
const decodedData = Buffer.from(encodedData, 'base64').toString()
34+
const metadata = JSON.parse(decodedData)
35+
36+
return metadata
37+
}

‎src/store.ts

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Client } from './client.ts'
2+
import { decodeMetadata, Metadata } from './metadata.ts'
23
import { BlobInput, HTTPMethod } from './types.ts'
34

45
interface BaseStoreOptions {
@@ -17,13 +18,14 @@ type StoreOptions = DeployStoreOptions | NamedStoreOptions
1718

1819
interface SetOptions {
1920
/**
20-
* Accepts an absolute date as a `Date` object, or a relative date as the
21-
* number of seconds from the current date.
21+
* Arbitrary metadata object to associate with an entry. Must be seralizable
22+
* to JSON.
2223
*/
23-
expiration?: Date | number
24+
metadata?: Metadata
2425
}
2526

26-
const EXPIRY_HEADER = 'x-nf-expires-at'
27+
type BlobWithMetadata = { etag?: string } & { metadata: Metadata }
28+
type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text'
2729

2830
export class Store {
2931
private client: Client
@@ -34,26 +36,6 @@ export class Store {
3436
this.name = 'deployID' in options ? `deploy:${options.deployID}` : options.name
3537
}
3638

37-
private static getExpirationHeaders(expiration: Date | number | undefined): Record<string, string> {
38-
if (typeof expiration === 'number') {
39-
return {
40-
[EXPIRY_HEADER]: (Date.now() + expiration).toString(),
41-
}
42-
}
43-
44-
if (expiration instanceof Date) {
45-
return {
46-
[EXPIRY_HEADER]: expiration.getTime().toString(),
47-
}
48-
}
49-
50-
if (expiration === undefined) {
51-
return {}
52-
}
53-
54-
throw new TypeError(`'expiration' value must be a number or a Date, ${typeof expiration} found.`)
55-
}
56-
5739
async delete(key: string) {
5840
await this.client.makeRequest({ key, method: HTTPMethod.DELETE, storeName: this.name })
5941
}
@@ -67,19 +49,10 @@ export class Store {
6749
async get(key: string, { type }: { type: 'text' }): Promise<string>
6850
async get(
6951
key: string,
70-
options?: { type: 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text' },
52+
options?: { type: BlobResponseType },
7153
): Promise<ArrayBuffer | Blob | ReadableStream | string | null> {
7254
const { type } = options ?? {}
7355
const res = await this.client.makeRequest({ key, method: HTTPMethod.GET, storeName: this.name })
74-
const expiration = res?.headers.get(EXPIRY_HEADER)
75-
76-
if (typeof expiration === 'string') {
77-
const expirationTS = Number.parseInt(expiration)
78-
79-
if (!Number.isNaN(expirationTS) && expirationTS <= Date.now()) {
80-
return null
81-
}
82-
}
8356

8457
if (res === null) {
8558
return res
@@ -108,29 +81,93 @@ export class Store {
10881
throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`)
10982
}
11083

111-
async set(key: string, data: BlobInput, { expiration }: SetOptions = {}) {
112-
const headers = Store.getExpirationHeaders(expiration)
84+
async getWithMetadata(key: string): Promise<{ data: string } & BlobWithMetadata>
85+
86+
async getWithMetadata(
87+
key: string,
88+
{ type }: { type: 'arrayBuffer' },
89+
): Promise<{ data: ArrayBuffer } & BlobWithMetadata>
90+
91+
async getWithMetadata(key: string, { type }: { type: 'blob' }): Promise<{ data: Blob } & BlobWithMetadata>
92+
93+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
94+
async getWithMetadata(key: string, { type }: { type: 'json' }): Promise<{ data: any } & BlobWithMetadata>
95+
96+
async getWithMetadata(key: string, { type }: { type: 'stream' }): Promise<{ data: ReadableStream } & BlobWithMetadata>
97+
98+
async getWithMetadata(key: string, { type }: { type: 'text' }): Promise<{ data: string } & BlobWithMetadata>
11399

100+
async getWithMetadata(
101+
key: string,
102+
options?: { type: BlobResponseType },
103+
): Promise<
104+
| ({
105+
data: ArrayBuffer | Blob | ReadableStream | string | null
106+
} & BlobWithMetadata)
107+
| null
108+
> {
109+
const { type } = options ?? {}
110+
const res = await this.client.makeRequest({ key, method: HTTPMethod.GET, storeName: this.name })
111+
const etag = res?.headers.get('etag') ?? undefined
112+
113+
let metadata: Metadata = {}
114+
115+
try {
116+
metadata = decodeMetadata(res?.headers)
117+
} catch {
118+
throw new Error(
119+
'An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client.',
120+
)
121+
}
122+
123+
if (res === null) {
124+
return null
125+
}
126+
127+
if (type === undefined || type === 'text') {
128+
return { data: await res.text(), etag, metadata }
129+
}
130+
131+
if (type === 'arrayBuffer') {
132+
return { data: await res.arrayBuffer(), etag, metadata }
133+
}
134+
135+
if (type === 'blob') {
136+
return { data: await res.blob(), etag, metadata }
137+
}
138+
139+
if (type === 'json') {
140+
return { data: await res.json(), etag, metadata }
141+
}
142+
143+
if (type === 'stream') {
144+
return { data: res.body, etag, metadata }
145+
}
146+
147+
throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`)
148+
}
149+
150+
async set(key: string, data: BlobInput, { metadata }: SetOptions = {}) {
114151
await this.client.makeRequest({
115152
body: data,
116-
headers,
117153
key,
154+
metadata,
118155
method: HTTPMethod.PUT,
119156
storeName: this.name,
120157
})
121158
}
122159

123-
async setJSON(key: string, data: unknown, { expiration }: SetOptions = {}) {
160+
async setJSON(key: string, data: unknown, { metadata }: SetOptions = {}) {
124161
const payload = JSON.stringify(data)
125162
const headers = {
126-
...Store.getExpirationHeaders(expiration),
127163
'content-type': 'application/json',
128164
}
129165

130166
await this.client.makeRequest({
131167
body: payload,
132168
headers,
133169
key,
170+
metadata,
134171
method: HTTPMethod.PUT,
135172
storeName: this.name,
136173
})

‎test/util.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { Buffer } from 'node:buffer'
22

3+
export const base64Encode = (input: string | object) => {
4+
const payload = typeof input === 'string' ? input : JSON.stringify(input)
5+
6+
return Buffer.from(payload).toString('base64')
7+
}
8+
39
export const streamToString = async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
410
// eslint-disable-next-line @typescript-eslint/no-explicit-any
511
const chunks: Array<any> = []

0 commit comments

Comments
 (0)
Please sign in to comment.