@fileway/supabase 0.0.0-stage → 0.6.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/LICENSE +21 -0
- package/README.md +78 -2
- package/dist/index.cjs +486 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +73 -0
- package/dist/index.d.ts +73 -0
- package/dist/index.js +459 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Azubuike Golden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @fileway/supabase
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@fileway/supabase)
|
|
4
|
+
[](https://bundlephobia.com/package/@fileway/supabase)
|
|
5
|
+
|
|
6
|
+
**Supabase Storage driver for [Fileway](https://github.com/Goldenhub/fileway).**
|
|
7
|
+
|
|
8
|
+
Implemented with `fetch` against the Storage REST and TUS APIs: no `supabase-js` required, so it runs on Node.js, Bun, Deno, and edge runtimes.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @fileway/supabase
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { FilewayClient } from "@fileway/core";
|
|
18
|
+
import { SupabaseDriver } from "@fileway/supabase";
|
|
19
|
+
|
|
20
|
+
// Server side, with a secret (service role) key: full access, bypasses RLS.
|
|
21
|
+
const driver = new SupabaseDriver({
|
|
22
|
+
url: "https://<project-ref>.supabase.co",
|
|
23
|
+
key: process.env.SUPABASE_SECRET_KEY,
|
|
24
|
+
bucket: "avatars",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const client = new FilewayClient({ driver });
|
|
28
|
+
|
|
29
|
+
const result = await client.upload(stream, {
|
|
30
|
+
filename: "photo.jpg",
|
|
31
|
+
metadata: { userId: "42" }, // stored as the object's user metadata
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To act as a signed-in user so the bucket's RLS policies apply, pass the anon or publishable key plus the user's access token:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
new SupabaseDriver({
|
|
39
|
+
url: "https://<project-ref>.supabase.co",
|
|
40
|
+
key: process.env.SUPABASE_PUBLISHABLE_KEY,
|
|
41
|
+
accessToken: session.access_token,
|
|
42
|
+
bucket: "avatars",
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Config
|
|
47
|
+
|
|
48
|
+
| Option | Type | Description |
|
|
49
|
+
| -------------- | -------- | ----------- |
|
|
50
|
+
| `url` | `string` | Project URL (required). `https://<ref>.storage.supabase.co` is faster for large uploads |
|
|
51
|
+
| `key` | `string` | API key (required): secret / service role, or anon / publishable with `accessToken` |
|
|
52
|
+
| `accessToken` | `string` | A user's JWT, so RLS policies apply |
|
|
53
|
+
| `bucket` | `string` | Bucket name (required) |
|
|
54
|
+
| `baseUrl` | `string` | Public base URL for `getUrl` (default: the bucket's public object URL) |
|
|
55
|
+
| `cacheControl` | `number` | `Cache-Control` max-age in seconds (default `3600`) |
|
|
56
|
+
| `maxSizeBytes` | `number` | Reject larger uploads with a `size-exceeded` error |
|
|
57
|
+
|
|
58
|
+
## Upload strategy
|
|
59
|
+
|
|
60
|
+
Uploads always overwrite (`x-upsert`) and memory stays bounded whatever the file size:
|
|
61
|
+
|
|
62
|
+
- **Up to 6 MiB**: one standard upload request.
|
|
63
|
+
- **Known size above 6 MiB**: a resumable TUS upload in 6 MiB chunks (the size Supabase requires). A failed or cancelled upload is terminated.
|
|
64
|
+
- **Unknown size above 6 MiB**: the stream is sent as the body of one standard upload, without buffering it. Supabase limits standard uploads to 5 GB, so pass `size` for larger files.
|
|
65
|
+
|
|
66
|
+
If the stream ends before or after a declared `size`, the upload throws a validation error and nothing is stored.
|
|
67
|
+
|
|
68
|
+
## URLs
|
|
69
|
+
|
|
70
|
+
`getUrl()` returns the public object URL, which only works for public buckets. For private buckets use `getPresignedUrl()` (a Supabase signed URL) or `get()`.
|
|
71
|
+
|
|
72
|
+
## Upload status
|
|
73
|
+
|
|
74
|
+
`pathFor` and `head` are implemented, so `client.getUploadStatus()` reports `complete` or `missing`, and lost upload responses are recovered without uploading twice.
|
|
75
|
+
|
|
76
|
+
## Resources
|
|
77
|
+
|
|
78
|
+
- [Documentation](https://www.usefileway.dev/docs/drivers/supabase)
|
|
79
|
+
- [License: MIT](https://github.com/Goldenhub/fileway/blob/main/LICENSE)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
SupabaseDriver: () => SupabaseDriver
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/errors.ts
|
|
28
|
+
function classifySupabaseError(status, body) {
|
|
29
|
+
let parsed = {};
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(body);
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
const text = `${parsed.error ?? ""} ${parsed.message ?? ""} ${parsed.error === void 0 ? body : ""}`.toLowerCase();
|
|
35
|
+
const code = Number(parsed.statusCode ?? status);
|
|
36
|
+
if (text.includes("bucket not found")) return "bucket-not-found";
|
|
37
|
+
if (code === 409 || text.includes("already exists") || text.includes("duplicate")) return "conflict";
|
|
38
|
+
if (text.includes("not found") || text.includes("not_found") || text.includes("no such")) return "not-found";
|
|
39
|
+
if (text.includes("exceeded the maximum allowed size") || text.includes("payload too large") || code === 413) {
|
|
40
|
+
return "size-exceeded";
|
|
41
|
+
}
|
|
42
|
+
if (code === 401 || code === 403 || text.includes("jwt") || text.includes("unauthorized") || text.includes("row-level security")) {
|
|
43
|
+
return "auth-failed";
|
|
44
|
+
}
|
|
45
|
+
if (code === 429 || text.includes("too many requests")) return "rate-limited";
|
|
46
|
+
if (code === 404) return "not-found";
|
|
47
|
+
return "provider-error";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/index.ts
|
|
51
|
+
var import_core = require("@fileway/core");
|
|
52
|
+
var randomUUID = () => globalThis.crypto.randomUUID();
|
|
53
|
+
var PROVIDER = "supabase";
|
|
54
|
+
var TUS_CHUNK_SIZE = 6 * 1024 * 1024;
|
|
55
|
+
var TUS_VERSION = "1.0.0";
|
|
56
|
+
var PRESIGN_DEFAULT_EXPIRY = 3600;
|
|
57
|
+
var TOKEN_KEY = "fileway-upload-token";
|
|
58
|
+
var MAX_CHUNK_RESENDS = 3;
|
|
59
|
+
var SupabaseDriver = class {
|
|
60
|
+
name = "supabase";
|
|
61
|
+
api;
|
|
62
|
+
bucket;
|
|
63
|
+
baseUrl;
|
|
64
|
+
headers;
|
|
65
|
+
maxSizeBytes;
|
|
66
|
+
cacheControl;
|
|
67
|
+
constructor(config) {
|
|
68
|
+
if (!config.url || !config.key || !config.bucket) {
|
|
69
|
+
throw new import_core.StorageError("config", "SupabaseDriver requires url, key and bucket", { provider: PROVIDER });
|
|
70
|
+
}
|
|
71
|
+
this.api = `${config.url.replace(/\/+$/, "")}/storage/v1`;
|
|
72
|
+
this.bucket = config.bucket;
|
|
73
|
+
this.baseUrl = (config.baseUrl ?? `${this.api}/object/public/${config.bucket}`).replace(/\/+$/, "");
|
|
74
|
+
const bearer = config.accessToken ?? (config.key.startsWith("eyJ") ? config.key : void 0);
|
|
75
|
+
this.headers = { apikey: config.key, ...bearer ? { authorization: `Bearer ${bearer}` } : {} };
|
|
76
|
+
this.maxSizeBytes = config.maxSizeBytes;
|
|
77
|
+
this.cacheControl = `max-age=${config.cacheControl ?? 3600}`;
|
|
78
|
+
}
|
|
79
|
+
async upload(stream, options, context) {
|
|
80
|
+
(0, import_core.validateUploadOptions)(options);
|
|
81
|
+
if (this.maxSizeBytes !== void 0 && options.size !== void 0 && options.size > this.maxSizeBytes) {
|
|
82
|
+
throw new import_core.StorageError("size-exceeded", `file size ${options.size} exceeds maxSizeBytes ${this.maxSizeBytes}`, { provider: PROVIDER });
|
|
83
|
+
}
|
|
84
|
+
const id = options.id ?? randomUUID();
|
|
85
|
+
const path = this.pathFor({ ...options, id });
|
|
86
|
+
const contentType = options.mimeType ?? "application/octet-stream";
|
|
87
|
+
const metadata = { ...options.metadata, ...context?.uploadToken ? { [TOKEN_KEY]: context.uploadToken } : {} };
|
|
88
|
+
const signal = options.signal;
|
|
89
|
+
const reader = stream.getReader();
|
|
90
|
+
let aborted = signal?.aborted ?? false;
|
|
91
|
+
const onAbort = () => {
|
|
92
|
+
aborted = true;
|
|
93
|
+
reader.cancel().catch(() => {
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
if (signal && !aborted) signal.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
let totalBytes = 0;
|
|
98
|
+
const read = async () => {
|
|
99
|
+
const { done, value } = await reader.read();
|
|
100
|
+
if (aborted) throw (0, import_core.abortError)("upload aborted");
|
|
101
|
+
if (done) return void 0;
|
|
102
|
+
totalBytes += value.byteLength;
|
|
103
|
+
if (this.maxSizeBytes !== void 0 && totalBytes > this.maxSizeBytes) {
|
|
104
|
+
throw new import_core.StorageError("size-exceeded", `upload exceeds maxSizeBytes ${this.maxSizeBytes}`, { provider: PROVIDER });
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
};
|
|
108
|
+
const checkSize = () => {
|
|
109
|
+
if (options.size !== void 0 && totalBytes !== options.size) {
|
|
110
|
+
throw new import_core.ValidationError(`stream delivered ${totalBytes} bytes, expected ${options.size}`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
let tusUrl;
|
|
114
|
+
try {
|
|
115
|
+
let etag;
|
|
116
|
+
if (options.size !== void 0 && options.size > TUS_CHUNK_SIZE) {
|
|
117
|
+
tusUrl = await this.createTusUpload(path, options.size, contentType, metadata, signal);
|
|
118
|
+
let buffer = [];
|
|
119
|
+
let bufferedLen = 0;
|
|
120
|
+
let offset = 0;
|
|
121
|
+
while (true) {
|
|
122
|
+
const value = await read();
|
|
123
|
+
if (totalBytes > options.size) checkSize();
|
|
124
|
+
if (value) {
|
|
125
|
+
buffer.push(value);
|
|
126
|
+
bufferedLen += value.byteLength;
|
|
127
|
+
}
|
|
128
|
+
while (bufferedLen >= TUS_CHUNK_SIZE || !value && bufferedLen > 0) {
|
|
129
|
+
if (!value) checkSize();
|
|
130
|
+
const merged = concatBytes(buffer, bufferedLen);
|
|
131
|
+
const take = Math.min(TUS_CHUNK_SIZE, bufferedLen);
|
|
132
|
+
offset = await this.patchTus(tusUrl, merged.subarray(0, take), offset, signal);
|
|
133
|
+
const rest = merged.subarray(take);
|
|
134
|
+
buffer = [rest];
|
|
135
|
+
bufferedLen = rest.byteLength;
|
|
136
|
+
}
|
|
137
|
+
if (!value) break;
|
|
138
|
+
}
|
|
139
|
+
checkSize();
|
|
140
|
+
} else {
|
|
141
|
+
const first = [];
|
|
142
|
+
let firstLen = 0;
|
|
143
|
+
let ended = false;
|
|
144
|
+
while (firstLen <= TUS_CHUNK_SIZE) {
|
|
145
|
+
const value = await read();
|
|
146
|
+
if (!value) {
|
|
147
|
+
ended = true;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
first.push(value);
|
|
151
|
+
firstLen += value.byteLength;
|
|
152
|
+
}
|
|
153
|
+
const head = concatBytes(first, firstLen);
|
|
154
|
+
if (ended) {
|
|
155
|
+
checkSize();
|
|
156
|
+
etag = await this.standardUpload(path, head, contentType, metadata, signal);
|
|
157
|
+
} else {
|
|
158
|
+
checkSize();
|
|
159
|
+
const bodyError = {};
|
|
160
|
+
const body = new ReadableStream({
|
|
161
|
+
start(controller) {
|
|
162
|
+
controller.enqueue(head);
|
|
163
|
+
},
|
|
164
|
+
async pull(controller) {
|
|
165
|
+
try {
|
|
166
|
+
const value = await read();
|
|
167
|
+
if (value) controller.enqueue(value);
|
|
168
|
+
else controller.close();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
bodyError.current = err;
|
|
171
|
+
controller.error(err);
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
cancel(reason) {
|
|
175
|
+
return reader.cancel(reason);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
try {
|
|
179
|
+
etag = await this.standardUpload(path, body, contentType, metadata, signal);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (bodyError.current) throw bodyError.current;
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
checkSize();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
id,
|
|
189
|
+
url: `${this.baseUrl}/${(0, import_core.urlEncodePath)(path)}`,
|
|
190
|
+
path,
|
|
191
|
+
size: totalBytes,
|
|
192
|
+
meta: { bucket: this.bucket, ...etag ? { etag } : {} }
|
|
193
|
+
};
|
|
194
|
+
} catch (err) {
|
|
195
|
+
reader.cancel().catch(() => {
|
|
196
|
+
});
|
|
197
|
+
if (tusUrl) {
|
|
198
|
+
await fetch(tusUrl, { method: "DELETE", headers: { ...this.headers, "tus-resumable": TUS_VERSION } }).catch(() => {
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
} finally {
|
|
203
|
+
signal?.removeEventListener("abort", onAbort);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** `POST /object/{bucket}/{path}`, overwriting any existing object. */
|
|
207
|
+
async standardUpload(path, body, contentType, metadata, signal) {
|
|
208
|
+
const res = await fetchWithAbort(this.objectUrl("object", path), {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
...this.headers,
|
|
212
|
+
"content-type": contentType,
|
|
213
|
+
"cache-control": this.cacheControl,
|
|
214
|
+
"x-upsert": "true",
|
|
215
|
+
"x-metadata": base64(JSON.stringify(metadata))
|
|
216
|
+
},
|
|
217
|
+
body,
|
|
218
|
+
// Node's fetch (undici) requires `duplex: "half"` for stream bodies;
|
|
219
|
+
// other runtimes ignore it.
|
|
220
|
+
duplex: "half",
|
|
221
|
+
signal: signal ?? null
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) throw await supabaseError("Supabase upload failed", res);
|
|
224
|
+
return res.headers.get("etag") ?? void 0;
|
|
225
|
+
}
|
|
226
|
+
/** Creates a TUS upload and returns its URL. */
|
|
227
|
+
async createTusUpload(path, size, contentType, metadata, signal) {
|
|
228
|
+
const endpoint = `${this.api}/upload/resumable`;
|
|
229
|
+
const uploadMetadata = {
|
|
230
|
+
bucketName: this.bucket,
|
|
231
|
+
objectName: path,
|
|
232
|
+
contentType,
|
|
233
|
+
cacheControl: this.cacheControl.replace("max-age=", ""),
|
|
234
|
+
metadata: JSON.stringify(metadata)
|
|
235
|
+
};
|
|
236
|
+
const res = await fetchWithAbort(endpoint, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: {
|
|
239
|
+
...this.headers,
|
|
240
|
+
"tus-resumable": TUS_VERSION,
|
|
241
|
+
"upload-length": String(size),
|
|
242
|
+
"upload-metadata": Object.entries(uploadMetadata).map(([k, v]) => `${k} ${base64(v)}`).join(","),
|
|
243
|
+
"x-upsert": "true"
|
|
244
|
+
},
|
|
245
|
+
signal: signal ?? null
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) throw await supabaseError("Supabase resumable upload create failed", res);
|
|
248
|
+
const location = res.headers.get("location");
|
|
249
|
+
if (!location) {
|
|
250
|
+
throw new import_core.StorageError("provider-error", "Supabase resumable upload returned no Location header", { provider: PROVIDER });
|
|
251
|
+
}
|
|
252
|
+
return new URL(location, endpoint).href;
|
|
253
|
+
}
|
|
254
|
+
/** PATCHes `bytes` at `offset`, resending whatever the server did not store.
|
|
255
|
+
* Returns the new offset. */
|
|
256
|
+
async patchTus(url, bytes, offset, signal) {
|
|
257
|
+
const end = offset + bytes.byteLength;
|
|
258
|
+
let current = offset;
|
|
259
|
+
for (let attempt = 0; attempt <= MAX_CHUNK_RESENDS && current < end; attempt++) {
|
|
260
|
+
const res = await fetchWithAbort(url, {
|
|
261
|
+
method: "PATCH",
|
|
262
|
+
headers: {
|
|
263
|
+
...this.headers,
|
|
264
|
+
"tus-resumable": TUS_VERSION,
|
|
265
|
+
"upload-offset": String(current),
|
|
266
|
+
"content-type": "application/offset+octet-stream"
|
|
267
|
+
},
|
|
268
|
+
body: bytes.subarray(current - offset),
|
|
269
|
+
signal: signal ?? null
|
|
270
|
+
});
|
|
271
|
+
if (!res.ok) throw await supabaseError("Supabase resumable upload chunk failed", res);
|
|
272
|
+
const next = Number(res.headers.get("upload-offset") ?? end);
|
|
273
|
+
if (next < current) break;
|
|
274
|
+
current = next;
|
|
275
|
+
}
|
|
276
|
+
if (current < end) {
|
|
277
|
+
throw new import_core.StorageError("provider-error", "Supabase did not store the uploaded chunk", { provider: PROVIDER });
|
|
278
|
+
}
|
|
279
|
+
return current;
|
|
280
|
+
}
|
|
281
|
+
/** Deletes the object; a missing object is not an error. */
|
|
282
|
+
async delete(path, options) {
|
|
283
|
+
const res = await fetchWithAbort(`${this.api}/object/${encodeURIComponent(this.bucket)}`, {
|
|
284
|
+
method: "DELETE",
|
|
285
|
+
headers: { ...this.headers, "content-type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ prefixes: [path] }),
|
|
287
|
+
signal: options?.signal ?? null
|
|
288
|
+
});
|
|
289
|
+
if (!res.ok) throw await supabaseError("Supabase delete failed", res);
|
|
290
|
+
}
|
|
291
|
+
async getUrl(path, _options) {
|
|
292
|
+
return `${this.baseUrl}/${(0, import_core.urlEncodePath)(path)}`;
|
|
293
|
+
}
|
|
294
|
+
/** Streams the object. `options.signal` also cancels reading the body. */
|
|
295
|
+
async get(path, options) {
|
|
296
|
+
const res = await fetchWithAbort(this.objectUrl("object/authenticated", path), {
|
|
297
|
+
headers: this.headers,
|
|
298
|
+
signal: options?.signal ?? null
|
|
299
|
+
});
|
|
300
|
+
if (!res.ok) throw await supabaseError("Supabase get failed", res);
|
|
301
|
+
if (!res.body) {
|
|
302
|
+
throw new import_core.StorageError("provider-error", "Supabase get returned no response body", { provider: PROVIDER });
|
|
303
|
+
}
|
|
304
|
+
return res.body;
|
|
305
|
+
}
|
|
306
|
+
/** A signed download URL for `path`, valid for `expiresIn` seconds (positive integer, default 3600). */
|
|
307
|
+
async getPresignedUrl(path, options) {
|
|
308
|
+
const expiresIn = options?.expiresIn ?? PRESIGN_DEFAULT_EXPIRY;
|
|
309
|
+
if (!Number.isInteger(expiresIn) || expiresIn < 1) {
|
|
310
|
+
throw new import_core.ValidationError("expiresIn must be a positive integer number of seconds");
|
|
311
|
+
}
|
|
312
|
+
const res = await fetchWithAbort(this.objectUrl("object/sign", path), {
|
|
313
|
+
method: "POST",
|
|
314
|
+
headers: { ...this.headers, "content-type": "application/json" },
|
|
315
|
+
body: JSON.stringify({ expiresIn }),
|
|
316
|
+
signal: options?.signal ?? null
|
|
317
|
+
});
|
|
318
|
+
if (!res.ok) throw await supabaseError("Supabase sign failed", res);
|
|
319
|
+
const { signedURL } = await res.json();
|
|
320
|
+
if (!signedURL) {
|
|
321
|
+
throw new import_core.StorageError("provider-error", "Supabase sign returned no signedURL", { provider: PROVIDER });
|
|
322
|
+
}
|
|
323
|
+
return `${this.api}${signedURL.startsWith("/") ? "" : "/"}${signedURL}`;
|
|
324
|
+
}
|
|
325
|
+
/** The object name an upload with `options.id` is stored under: `<path>/<id>.<ext>`. */
|
|
326
|
+
pathFor(options) {
|
|
327
|
+
const ext = options.filename.includes(".") ? options.filename.split(".").pop() : "";
|
|
328
|
+
const filename = ext ? `${options.id}.${ext}` : options.id;
|
|
329
|
+
return options.path ? `${options.path}/${filename}` : filename;
|
|
330
|
+
}
|
|
331
|
+
/** The object at `path` (`GET /object/info`), or `null` if it does not exist. */
|
|
332
|
+
async head(path, options) {
|
|
333
|
+
const res = await fetchWithAbort(this.objectUrl("object/info", path), {
|
|
334
|
+
headers: this.headers,
|
|
335
|
+
signal: options?.signal ?? null
|
|
336
|
+
});
|
|
337
|
+
if (!res.ok) {
|
|
338
|
+
const error = await supabaseError("Supabase info failed", res);
|
|
339
|
+
if (error.code === "not-found") return null;
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
const info = await res.json();
|
|
343
|
+
const name = path.split("/").pop() ?? path;
|
|
344
|
+
const token = info.metadata?.[TOKEN_KEY];
|
|
345
|
+
return {
|
|
346
|
+
id: name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name,
|
|
347
|
+
url: `${this.baseUrl}/${(0, import_core.urlEncodePath)(path)}`,
|
|
348
|
+
path,
|
|
349
|
+
size: Number(info.size ?? 0),
|
|
350
|
+
meta: { bucket: this.bucket, ...info.etag ? { etag: info.etag } : {} },
|
|
351
|
+
...token ? { uploadToken: token } : {},
|
|
352
|
+
...info.last_modified ? { lastModified: new Date(info.last_modified) } : {},
|
|
353
|
+
...info.content_type ? { contentType: info.content_type } : {}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* One page of the objects under `options.prefix`. Supabase lists one folder
|
|
358
|
+
* at a time, so `recursive: true` walks folders in turn: results come folder
|
|
359
|
+
* by folder rather than in one global sort order.
|
|
360
|
+
*/
|
|
361
|
+
async list(options) {
|
|
362
|
+
const prefix = options?.prefix ?? "";
|
|
363
|
+
const limit = options?.limit ?? 100;
|
|
364
|
+
const cut = prefix.lastIndexOf("/") + 1;
|
|
365
|
+
const top = prefix.slice(0, cut);
|
|
366
|
+
const namePart = prefix.slice(cut);
|
|
367
|
+
const state = options?.cursor !== void 0 ? (0, import_core.decodeCursor)(options.cursor) : { queue: [top], offset: 0 };
|
|
368
|
+
const objects = [];
|
|
369
|
+
const folders = [];
|
|
370
|
+
while (state.queue.length > 0 && objects.length + folders.length < limit) {
|
|
371
|
+
const folder = state.queue[0];
|
|
372
|
+
const want = limit - objects.length - folders.length;
|
|
373
|
+
const search = folder === top ? namePart : "";
|
|
374
|
+
const items = await this.listFolder(folder, state.offset, want, search, options?.signal);
|
|
375
|
+
for (const item of items) {
|
|
376
|
+
if (!item.name.startsWith(search)) continue;
|
|
377
|
+
if (item.id === null) {
|
|
378
|
+
if (options?.recursive) state.queue.push(`${folder}${item.name}/`);
|
|
379
|
+
else folders.push(`${folder}${item.name}/`);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const modified = item.metadata?.lastModified ?? item.updated_at;
|
|
383
|
+
const etag = item.metadata?.eTag;
|
|
384
|
+
const type = item.metadata?.mimetype;
|
|
385
|
+
objects.push({
|
|
386
|
+
path: `${folder}${item.name}`,
|
|
387
|
+
size: Number(item.metadata?.size ?? 0),
|
|
388
|
+
...modified ? { lastModified: new Date(modified) } : {},
|
|
389
|
+
...etag ? { etag } : {},
|
|
390
|
+
...type ? { contentType: type } : {}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (items.length < want) {
|
|
394
|
+
state.queue.shift();
|
|
395
|
+
state.offset = 0;
|
|
396
|
+
if (!options?.recursive) break;
|
|
397
|
+
} else {
|
|
398
|
+
state.offset += items.length;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { objects, folders, ...state.queue.length > 0 ? { cursor: (0, import_core.encodeCursor)(state) } : {} };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Copies an object in this bucket. With `overwrite: false` the destination is
|
|
405
|
+
* checked first, which is not atomic; with `overwrite: true` an existing
|
|
406
|
+
* destination is replaced.
|
|
407
|
+
*/
|
|
408
|
+
async copy(from, to, options) {
|
|
409
|
+
await this.transfer("copy", from, to, options);
|
|
410
|
+
}
|
|
411
|
+
/** Moves (renames) an object in this bucket, with the same overwrite rules as `copy`. */
|
|
412
|
+
async move(from, to, options) {
|
|
413
|
+
await this.transfer("move", from, to, options);
|
|
414
|
+
}
|
|
415
|
+
async transfer(op, from, to, options) {
|
|
416
|
+
if (from === to) throw new import_core.ValidationError("from and to must be different paths");
|
|
417
|
+
const signal = options?.signal;
|
|
418
|
+
const exists = await this.head(to, signal ? { signal } : void 0);
|
|
419
|
+
if (exists && !options?.overwrite) {
|
|
420
|
+
throw new import_core.StorageError("conflict", `Supabase ${op} failed: ${to} already exists`, { provider: PROVIDER });
|
|
421
|
+
}
|
|
422
|
+
if (exists) await this.delete(to, signal ? { signal } : void 0);
|
|
423
|
+
const res = await fetchWithAbort(`${this.api}/object/${op}`, {
|
|
424
|
+
method: "POST",
|
|
425
|
+
headers: { ...this.headers, "content-type": "application/json" },
|
|
426
|
+
body: JSON.stringify({ bucketId: this.bucket, sourceKey: from, destinationKey: to }),
|
|
427
|
+
signal: signal ?? null
|
|
428
|
+
});
|
|
429
|
+
if (!res.ok) throw await supabaseError(`Supabase ${op} failed`, res);
|
|
430
|
+
}
|
|
431
|
+
async listFolder(folder, offset, limit, search, signal) {
|
|
432
|
+
const res = await fetchWithAbort(`${this.api}/object/list/${encodeURIComponent(this.bucket)}`, {
|
|
433
|
+
method: "POST",
|
|
434
|
+
headers: { ...this.headers, "content-type": "application/json" },
|
|
435
|
+
body: JSON.stringify({
|
|
436
|
+
prefix: folder.replace(/\/$/, ""),
|
|
437
|
+
limit,
|
|
438
|
+
offset,
|
|
439
|
+
sortBy: { column: "name", order: "asc" },
|
|
440
|
+
...search ? { search } : {}
|
|
441
|
+
}),
|
|
442
|
+
signal: signal ?? null
|
|
443
|
+
});
|
|
444
|
+
if (!res.ok) throw await supabaseError("Supabase list failed", res);
|
|
445
|
+
return await res.json();
|
|
446
|
+
}
|
|
447
|
+
objectUrl(route, path) {
|
|
448
|
+
return `${this.api}/${route}/${encodeURIComponent(this.bucket)}/${(0, import_core.urlEncodePath)(path)}`;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
async function fetchWithAbort(url, init) {
|
|
452
|
+
try {
|
|
453
|
+
return await fetch(url, init);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
if ((0, import_core.isAbortError)(err)) throw err;
|
|
456
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
457
|
+
throw new import_core.StorageError("network", `${PROVIDER} network error: ${cause.message}`, { provider: PROVIDER, cause });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function supabaseError(prefix, res) {
|
|
461
|
+
const body = await res.text().catch(() => "");
|
|
462
|
+
return new import_core.StorageError(
|
|
463
|
+
classifySupabaseError(res.status, body),
|
|
464
|
+
`${prefix}: ${res.status} ${res.statusText}${body ? `: ${body}` : ""}`,
|
|
465
|
+
{ statusCode: res.status, provider: PROVIDER }
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
function base64(value) {
|
|
469
|
+
let binary = "";
|
|
470
|
+
for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte);
|
|
471
|
+
return btoa(binary);
|
|
472
|
+
}
|
|
473
|
+
function concatBytes(chunks, total) {
|
|
474
|
+
const out = new Uint8Array(total);
|
|
475
|
+
let offset = 0;
|
|
476
|
+
for (const chunk of chunks) {
|
|
477
|
+
out.set(chunk, offset);
|
|
478
|
+
offset += chunk.byteLength;
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
483
|
+
0 && (module.exports = {
|
|
484
|
+
SupabaseDriver
|
|
485
|
+
});
|
|
486
|
+
//# sourceMappingURL=index.cjs.map
|