@meith/drivers 0.18.0 → 0.20.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/drivers",
3
- "version": "0.18.0",
3
+ "version": "0.20.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.18.0",
41
- "@meith/core": "0.18.0",
42
- "@meith/db": "0.18.0",
43
- "@meith/i18n": "0.18.0",
44
- "@meith/settings": "0.18.0"
40
+ "@meith/attachments": "0.20.0",
41
+ "@meith/core": "0.20.0",
42
+ "@meith/db": "0.20.0",
43
+ "@meith/i18n": "0.20.0",
44
+ "@meith/settings": "0.20.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 interface BlobFileStoreConfig {
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 interface BlobPutOptions {
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 interface BlobReadOptions {
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 interface BlobListOptions {
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: { readonly token: string }): Promise<void>
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 [vendor, product, scope, storeId] = token.split('_')
60
- if (vendor !== 'vercel' || product !== 'blob' || scope !== 'rw' || !storeId) {
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
- private readonly config: BlobFileStoreConfig,
78
- blob?: BlobLike,
79
- ) {
80
- this.storeId = storeIdFrom(config.token)
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: { BLOB_READ_WRITE_TOKEN?: string | undefined }): BlobFileStore {
88
- if (!env.BLOB_READ_WRITE_TOKEN) {
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 Vercel Blob store ' +
91
- 'attached to the project publishes it. See .env.example.',
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: env.BLOB_READ_WRITE_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 blob.put(key, Buffer.from(body.buffer, body.byteOffset, body.byteLength), {
107
- access: BLOB_ACCESS,
108
- token: this.config.token,
109
- contentType: options.contentType,
110
- addRandomSuffix: false,
111
- allowOverwrite: true,
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 blob.get(key, {
123
- access: BLOB_ACCESS,
124
- token: this.config.token,
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 blob.list({
141
- token: this.config.token,
142
- ...(cursor === undefined ? {} : { cursor }),
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, { token: this.config.token })
199
+ await this.attempt(() => blob.del(key, { ...this.auth }))
157
200
  } catch (error) {
158
201
  if (isNotFound(error)) return
159
202
  throw error