@meith/drivers 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/src/files/blob-file-store.ts +82 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/drivers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"nodemailer": "^9.0.5",
|
|
38
38
|
"redis": "^6.2.1",
|
|
39
39
|
"shiki": "^4.4.3",
|
|
40
|
-
"@meith/attachments": "0.
|
|
41
|
-
"@meith/
|
|
42
|
-
"@meith/
|
|
43
|
-
"@meith/
|
|
44
|
-
"@meith/settings": "0.
|
|
40
|
+
"@meith/attachments": "0.19.0",
|
|
41
|
+
"@meith/db": "0.19.0",
|
|
42
|
+
"@meith/i18n": "0.19.0",
|
|
43
|
+
"@meith/core": "0.19.0",
|
|
44
|
+
"@meith/settings": "0.19.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"next": "16.3.1"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
blobStoreIdFromToken,
|
|
2
3
|
ConfigurationError,
|
|
3
4
|
type FileStore,
|
|
4
5
|
type PutFileOptions,
|
|
@@ -9,26 +10,25 @@ import { assertUsableKey, encodeKeyPath } from './keys'
|
|
|
9
10
|
|
|
10
11
|
export const BLOB_ACCESS = 'private'
|
|
11
12
|
|
|
12
|
-
export
|
|
13
|
-
readonly token: string
|
|
14
|
-
}
|
|
13
|
+
export type BlobFileStoreConfig =
|
|
14
|
+
| { readonly token: string; readonly storeId?: undefined }
|
|
15
|
+
| { readonly storeId: string; readonly token?: undefined }
|
|
16
|
+
|
|
17
|
+
export type BlobAuthOptions = { readonly token: string } | { readonly storeId: string }
|
|
15
18
|
|
|
16
|
-
export
|
|
19
|
+
export type BlobPutOptions = BlobAuthOptions & {
|
|
17
20
|
readonly access: typeof BLOB_ACCESS
|
|
18
|
-
readonly token: string
|
|
19
21
|
readonly contentType: string
|
|
20
22
|
readonly addRandomSuffix: boolean
|
|
21
23
|
readonly allowOverwrite: boolean
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
export
|
|
26
|
+
export type BlobReadOptions = BlobAuthOptions & {
|
|
25
27
|
readonly access: typeof BLOB_ACCESS
|
|
26
|
-
readonly token: string
|
|
27
28
|
readonly useCache: boolean
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export
|
|
31
|
-
readonly token: string
|
|
31
|
+
export type BlobListOptions = BlobAuthOptions & {
|
|
32
32
|
readonly cursor?: string
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -44,7 +44,7 @@ export interface BlobLike {
|
|
|
44
44
|
pathname: string,
|
|
45
45
|
options: BlobReadOptions,
|
|
46
46
|
): Promise<{ readonly stream: ReadableStream<Uint8Array> | null } | null>
|
|
47
|
-
del(pathname: string, options:
|
|
47
|
+
del(pathname: string, options: BlobAuthOptions): Promise<void>
|
|
48
48
|
list(options: BlobListOptions): Promise<BlobListPage>
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -55,9 +55,21 @@ function isNotFound(error: unknown): boolean {
|
|
|
55
55
|
return named === 'BlobNotFoundError' || message.includes('The requested blob does not exist')
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export const BLOB_NO_CREDENTIALS = 'No blob credentials found'
|
|
59
|
+
|
|
60
|
+
function normalizeStoreId(storeId: string): string {
|
|
61
|
+
const trimmed = storeId.trim()
|
|
62
|
+
return trimmed.startsWith('store_') ? trimmed.slice('store_'.length) : trimmed
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function needsCredentials(error: unknown): boolean {
|
|
66
|
+
const message = (error as { message?: string } | null)?.message ?? ''
|
|
67
|
+
return message.includes(BLOB_NO_CREDENTIALS)
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
function storeIdFrom(token: string): string {
|
|
59
|
-
const
|
|
60
|
-
if (
|
|
71
|
+
const storeId = blobStoreIdFromToken(token)
|
|
72
|
+
if (storeId === undefined) {
|
|
61
73
|
throw new ConfigurationError(
|
|
62
74
|
'BLOB_READ_WRITE_TOKEN is not a Vercel Blob read-write token. Copy the ' +
|
|
63
75
|
'value the Blob store published, which starts with vercel_blob_rw_.',
|
|
@@ -69,29 +81,44 @@ function storeIdFrom(token: string): string {
|
|
|
69
81
|
export class BlobFileStore implements FileStore {
|
|
70
82
|
private readonly storeId: string
|
|
71
83
|
|
|
84
|
+
private readonly auth: BlobAuthOptions
|
|
85
|
+
|
|
72
86
|
private readonly load: () => Promise<BlobLike>
|
|
73
87
|
|
|
74
88
|
private loading: Promise<BlobLike> | undefined
|
|
75
89
|
|
|
76
|
-
constructor(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
constructor(config: BlobFileStoreConfig, blob?: BlobLike) {
|
|
91
|
+
this.auth =
|
|
92
|
+
config.token === undefined ? { storeId: config.storeId.trim() } : { token: config.token }
|
|
93
|
+
this.storeId =
|
|
94
|
+
config.token === undefined ? normalizeStoreId(config.storeId) : storeIdFrom(config.token)
|
|
81
95
|
this.load =
|
|
82
96
|
blob === undefined
|
|
83
97
|
? async () => (await import('@vercel/blob')) satisfies BlobLike
|
|
84
98
|
: () => Promise.resolve(blob)
|
|
85
99
|
}
|
|
86
100
|
|
|
87
|
-
static fromEnv(env: {
|
|
88
|
-
|
|
101
|
+
static fromEnv(env: {
|
|
102
|
+
BLOB_READ_WRITE_TOKEN?: string | undefined
|
|
103
|
+
BLOB_STORE_ID?: string | undefined
|
|
104
|
+
}): BlobFileStore {
|
|
105
|
+
const token = env.BLOB_READ_WRITE_TOKEN
|
|
106
|
+
const storeId = env.BLOB_STORE_ID
|
|
107
|
+
|
|
108
|
+
if (token === undefined && storeId === undefined) {
|
|
89
109
|
throw new ConfigurationError(
|
|
90
|
-
'FILESTORE_DRIVER=blob requires BLOB_READ_WRITE_TOKEN. A
|
|
91
|
-
'attached to the project publishes
|
|
110
|
+
'FILESTORE_DRIVER=blob requires BLOB_STORE_ID or BLOB_READ_WRITE_TOKEN. A ' +
|
|
111
|
+
'Vercel Blob store attached to the project publishes BLOB_STORE_ID, which ' +
|
|
112
|
+
"the board uses with the deployment's OIDC token; a read-write token you " +
|
|
113
|
+
'create on the store yourself is the other way in. See .env.example.',
|
|
92
114
|
)
|
|
93
115
|
}
|
|
94
|
-
return new BlobFileStore({ token:
|
|
116
|
+
if (storeId === undefined) return new BlobFileStore({ token: token as string })
|
|
117
|
+
if (token === undefined) return new BlobFileStore({ storeId })
|
|
118
|
+
|
|
119
|
+
return storeIdFrom(token) === normalizeStoreId(storeId)
|
|
120
|
+
? new BlobFileStore({ storeId })
|
|
121
|
+
: new BlobFileStore({ token })
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
private blob(): Promise<BlobLike> {
|
|
@@ -99,17 +126,36 @@ export class BlobFileStore implements FileStore {
|
|
|
99
126
|
return this.loading
|
|
100
127
|
}
|
|
101
128
|
|
|
129
|
+
private async attempt<T>(operation: () => Promise<T>): Promise<T> {
|
|
130
|
+
try {
|
|
131
|
+
return await operation()
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!needsCredentials(error) || !('storeId' in this.auth)) throw error
|
|
134
|
+
throw new ConfigurationError(
|
|
135
|
+
`The Blob store ${this.storeId} was reached with no usable credential. ` +
|
|
136
|
+
'BLOB_STORE_ID is set, so the board asked the Vercel SDK to authenticate ' +
|
|
137
|
+
"with the deployment's OIDC token, and the platform supplied none: either " +
|
|
138
|
+
'OIDC is turned off for this project, or this is running outside a Vercel ' +
|
|
139
|
+
'deployment, which a command run on your own machine is. Create a ' +
|
|
140
|
+
'read-write token on the store and set BLOB_READ_WRITE_TOKEN, which works ' +
|
|
141
|
+
'in both places.',
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
102
146
|
async put(key: string, body: Uint8Array, options: PutFileOptions): Promise<StoredFile> {
|
|
103
147
|
assertUsableKey(key)
|
|
104
148
|
|
|
105
149
|
const blob = await this.blob()
|
|
106
|
-
await
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
150
|
+
await this.attempt(() =>
|
|
151
|
+
blob.put(key, Buffer.from(body.buffer, body.byteOffset, body.byteLength), {
|
|
152
|
+
...this.auth,
|
|
153
|
+
access: BLOB_ACCESS,
|
|
154
|
+
contentType: options.contentType,
|
|
155
|
+
addRandomSuffix: false,
|
|
156
|
+
allowOverwrite: true,
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
113
159
|
|
|
114
160
|
return { key, size: body.byteLength, contentType: options.contentType }
|
|
115
161
|
}
|
|
@@ -119,11 +165,9 @@ export class BlobFileStore implements FileStore {
|
|
|
119
165
|
|
|
120
166
|
const blob = await this.blob()
|
|
121
167
|
try {
|
|
122
|
-
const found = await
|
|
123
|
-
access: BLOB_ACCESS,
|
|
124
|
-
|
|
125
|
-
useCache: false,
|
|
126
|
-
})
|
|
168
|
+
const found = await this.attempt(() =>
|
|
169
|
+
blob.get(key, { ...this.auth, access: BLOB_ACCESS, useCache: false }),
|
|
170
|
+
)
|
|
127
171
|
if (found === null || found.stream === null) return undefined
|
|
128
172
|
return new Uint8Array(await new Response(found.stream).arrayBuffer())
|
|
129
173
|
} catch (error) {
|
|
@@ -137,10 +181,9 @@ export class BlobFileStore implements FileStore {
|
|
|
137
181
|
let cursor: string | undefined
|
|
138
182
|
|
|
139
183
|
do {
|
|
140
|
-
const page = await
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
})
|
|
184
|
+
const page = await this.attempt(() =>
|
|
185
|
+
blob.list({ ...this.auth, ...(cursor === undefined ? {} : { cursor }) }),
|
|
186
|
+
)
|
|
144
187
|
|
|
145
188
|
for (const entry of page.blobs) yield entry.pathname
|
|
146
189
|
|
|
@@ -153,7 +196,7 @@ export class BlobFileStore implements FileStore {
|
|
|
153
196
|
|
|
154
197
|
const blob = await this.blob()
|
|
155
198
|
try {
|
|
156
|
-
await blob.del(key, {
|
|
199
|
+
await this.attempt(() => blob.del(key, { ...this.auth }))
|
|
157
200
|
} catch (error) {
|
|
158
201
|
if (isNotFound(error)) return
|
|
159
202
|
throw error
|