@fileway/azure 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 +80 -2
- package/dist/index.cjs +573 -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 +548 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -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,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @fileway/azure
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@fileway/azure)
|
|
4
|
+
[](https://bundlephobia.com/package/@fileway/azure)
|
|
5
|
+
|
|
6
|
+
**Azure Blob Storage driver for [Fileway](https://github.com/Goldenhub/fileway).**
|
|
7
|
+
|
|
8
|
+
Implemented with `fetch` and Web Crypto (Shared Key and SAS signing): no Azure SDK required, so it runs on Node.js, Bun, Deno, and edge runtimes.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @fileway/azure
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { FilewayClient } from "@fileway/core";
|
|
18
|
+
import { AzureDriver } from "@fileway/azure";
|
|
19
|
+
|
|
20
|
+
const driver = new AzureDriver({
|
|
21
|
+
container: "uploads",
|
|
22
|
+
accountName: "mystorageaccount",
|
|
23
|
+
accountKey: process.env.AZURE_STORAGE_KEY,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const client = new FilewayClient({ driver });
|
|
27
|
+
|
|
28
|
+
const result = await client.upload(stream, {
|
|
29
|
+
filename: "photo.jpg",
|
|
30
|
+
metadata: { userId: "42" }, // stored as x-ms-meta-*
|
|
31
|
+
});
|
|
32
|
+
// result.meta.container, result.meta.etag
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or pass a connection string from the Azure portal:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
new AzureDriver({ container: "uploads", connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Config
|
|
42
|
+
|
|
43
|
+
| Option | Type | Description |
|
|
44
|
+
| ------------------ | -------- | ----------- |
|
|
45
|
+
| `container` | `string` | Container name (required) |
|
|
46
|
+
| `accountName` | `string` | Storage account name |
|
|
47
|
+
| `accountKey` | `string` | Account key (Shared Key auth; also needed for presigned URLs) |
|
|
48
|
+
| `sasToken` | `string` | A SAS token, used instead of an account key |
|
|
49
|
+
| `connectionString` | `string` | Connection string; explicit options override its values. `UseDevelopmentStorage=true` targets Azurite |
|
|
50
|
+
| `endpoint` | `string` | Blob service endpoint (default `https://<account>.blob.core.windows.net`) |
|
|
51
|
+
| `baseUrl` | `string` | Public base URL for `getUrl` (e.g. a CDN) |
|
|
52
|
+
| `blockSize` | `number` | Block size in bytes (default `8 MiB`, range `1 MiB` to `4000 MiB`) |
|
|
53
|
+
| `maxSizeBytes` | `number` | Reject larger uploads with a `size-exceeded` error |
|
|
54
|
+
|
|
55
|
+
## Upload strategy
|
|
56
|
+
|
|
57
|
+
The stream is read one block at a time, so memory stays bounded by `blockSize` whatever the file size:
|
|
58
|
+
|
|
59
|
+
- **Fits in one block**: a single `Put Blob`.
|
|
60
|
+
- **Larger, or unknown size**: `Put Block` for each block, then `Put Block List` to commit them. Blocks staged by a failed or cancelled upload are never visible and Azure discards them after 7 days.
|
|
61
|
+
|
|
62
|
+
If the stream ends before or after a declared `size`, nothing is committed and the upload throws a validation error.
|
|
63
|
+
|
|
64
|
+
## Metadata
|
|
65
|
+
|
|
66
|
+
Azure metadata names must be valid identifiers (letters, digits and underscores, not starting with a digit), so keys like `user-id` throw a validation error. Use `userId` or `user_id`.
|
|
67
|
+
|
|
68
|
+
## Upload status
|
|
69
|
+
|
|
70
|
+
`pathFor`, `head` and `isPending` are implemented, so `client.getUploadStatus()` reports `complete`, `pending` (blocks staged, not committed) or `missing`, and lost upload responses are recovered without uploading twice.
|
|
71
|
+
|
|
72
|
+
## Azurite
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
new AzureDriver({ container: "uploads", connectionString: "UseDevelopmentStorage=true" });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Resources
|
|
79
|
+
|
|
80
|
+
- [Documentation](https://www.usefileway.dev/docs/drivers/azure)
|
|
81
|
+
- [License: MIT](https://github.com/Goldenhub/fileway/blob/main/LICENSE)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
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
|
+
AzureDriver: () => AzureDriver
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
var import_core = require("@fileway/core");
|
|
27
|
+
|
|
28
|
+
// src/sharedkey.ts
|
|
29
|
+
var SERVICE_VERSION = "2023-11-03";
|
|
30
|
+
var encoder = new TextEncoder();
|
|
31
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
32
|
+
function importKey(accountKey) {
|
|
33
|
+
let key = keyCache.get(accountKey);
|
|
34
|
+
if (!key) {
|
|
35
|
+
key = globalThis.crypto.subtle.importKey(
|
|
36
|
+
"raw",
|
|
37
|
+
base64ToBytes(accountKey),
|
|
38
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
39
|
+
false,
|
|
40
|
+
["sign"]
|
|
41
|
+
);
|
|
42
|
+
keyCache.set(accountKey, key);
|
|
43
|
+
}
|
|
44
|
+
return key;
|
|
45
|
+
}
|
|
46
|
+
async function hmacBase64(accountKey, message) {
|
|
47
|
+
const key = await importKey(accountKey);
|
|
48
|
+
const sig = await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
|
49
|
+
return bytesToBase64(new Uint8Array(sig));
|
|
50
|
+
}
|
|
51
|
+
function buildStringToSign(method, url, accountName, headers, contentLength) {
|
|
52
|
+
const lower = {};
|
|
53
|
+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
|
|
54
|
+
const h = (name) => lower[name] ?? "";
|
|
55
|
+
const canonicalHeaders = Object.keys(lower).filter((k) => k.startsWith("x-ms-")).sort().map((k) => `${k}:${(lower[k] ?? "").replace(/\s+/g, " ").trim()}
|
|
56
|
+
`).join("");
|
|
57
|
+
const params = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const [k, v] of url.searchParams) {
|
|
59
|
+
const name = k.toLowerCase();
|
|
60
|
+
params.set(name, [...params.get(name) ?? [], v]);
|
|
61
|
+
}
|
|
62
|
+
const canonicalQuery = [...params.keys()].sort().map((k) => `
|
|
63
|
+
${k}:${(params.get(k) ?? []).sort().join(",")}`).join("");
|
|
64
|
+
const canonicalResource = `/${accountName}${url.pathname}${canonicalQuery}`;
|
|
65
|
+
return [
|
|
66
|
+
method.toUpperCase(),
|
|
67
|
+
h("content-encoding"),
|
|
68
|
+
h("content-language"),
|
|
69
|
+
contentLength ? String(contentLength) : "",
|
|
70
|
+
h("content-md5"),
|
|
71
|
+
h("content-type"),
|
|
72
|
+
h("date"),
|
|
73
|
+
h("if-modified-since"),
|
|
74
|
+
h("if-match"),
|
|
75
|
+
h("if-none-match"),
|
|
76
|
+
h("if-unmodified-since"),
|
|
77
|
+
h("range"),
|
|
78
|
+
canonicalHeaders + canonicalResource
|
|
79
|
+
].join("\n");
|
|
80
|
+
}
|
|
81
|
+
async function signRequest(method, url, accountName, accountKey, headers, contentLength, now = /* @__PURE__ */ new Date()) {
|
|
82
|
+
const signed = {
|
|
83
|
+
...headers,
|
|
84
|
+
"x-ms-date": now.toUTCString(),
|
|
85
|
+
"x-ms-version": SERVICE_VERSION
|
|
86
|
+
};
|
|
87
|
+
const stringToSign = buildStringToSign(method, url, accountName, signed, contentLength);
|
|
88
|
+
const signature = await hmacBase64(accountKey, stringToSign);
|
|
89
|
+
return { ...signed, authorization: `SharedKey ${accountName}:${signature}` };
|
|
90
|
+
}
|
|
91
|
+
async function blobReadSas(accountName, accountKey, container, blobName, expiry) {
|
|
92
|
+
const se = expiry.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
93
|
+
const fields = {
|
|
94
|
+
sp: "r",
|
|
95
|
+
st: "",
|
|
96
|
+
se,
|
|
97
|
+
resource: `/blob/${accountName}/${container}/${blobName}`,
|
|
98
|
+
si: "",
|
|
99
|
+
sip: "",
|
|
100
|
+
spr: "",
|
|
101
|
+
sv: SERVICE_VERSION,
|
|
102
|
+
sr: "b",
|
|
103
|
+
snapshot: "",
|
|
104
|
+
ses: "",
|
|
105
|
+
rscc: "",
|
|
106
|
+
rscd: "",
|
|
107
|
+
rsce: "",
|
|
108
|
+
rscl: "",
|
|
109
|
+
rsct: ""
|
|
110
|
+
};
|
|
111
|
+
const signature = await hmacBase64(accountKey, Object.values(fields).join("\n"));
|
|
112
|
+
const query = new URLSearchParams({ sv: SERVICE_VERSION, se, sr: "b", sp: "r", sig: signature });
|
|
113
|
+
return query.toString();
|
|
114
|
+
}
|
|
115
|
+
function base64ToBytes(value) {
|
|
116
|
+
const binary = atob(value);
|
|
117
|
+
const out = new Uint8Array(binary.length);
|
|
118
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function bytesToBase64(bytes) {
|
|
122
|
+
let binary = "";
|
|
123
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
124
|
+
return btoa(binary);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/internal.ts
|
|
128
|
+
var DEV_ACCOUNT = "devstoreaccount1";
|
|
129
|
+
var DEV_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
|
|
130
|
+
var DEV_ENDPOINT = "http://127.0.0.1:10000/devstoreaccount1";
|
|
131
|
+
function parseConnectionString(value) {
|
|
132
|
+
const parts = {};
|
|
133
|
+
for (const segment of value.split(";")) {
|
|
134
|
+
const eq = segment.indexOf("=");
|
|
135
|
+
if (eq > 0) parts[segment.slice(0, eq).trim()] = segment.slice(eq + 1).trim();
|
|
136
|
+
}
|
|
137
|
+
if (parts.UseDevelopmentStorage === "true") {
|
|
138
|
+
return { accountName: DEV_ACCOUNT, accountKey: DEV_KEY, endpoint: DEV_ENDPOINT };
|
|
139
|
+
}
|
|
140
|
+
const accountName = parts.AccountName;
|
|
141
|
+
const endpoint = parts.BlobEndpoint ?? (accountName ? `${parts.DefaultEndpointsProtocol ?? "https"}://${accountName}.blob.${parts.EndpointSuffix ?? "core.windows.net"}` : void 0);
|
|
142
|
+
return {
|
|
143
|
+
...accountName ? { accountName } : {},
|
|
144
|
+
...parts.AccountKey ? { accountKey: parts.AccountKey } : {},
|
|
145
|
+
...parts.SharedAccessSignature ? { sasToken: parts.SharedAccessSignature } : {},
|
|
146
|
+
...endpoint ? { endpoint } : {}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function classifyAzureError(status, code) {
|
|
150
|
+
switch (code) {
|
|
151
|
+
case "ContainerNotFound":
|
|
152
|
+
case "ContainerBeingDeleted":
|
|
153
|
+
return "bucket-not-found";
|
|
154
|
+
case "BlobNotFound":
|
|
155
|
+
case "CannotVerifyCopySource":
|
|
156
|
+
case "ResourceNotFound":
|
|
157
|
+
return "not-found";
|
|
158
|
+
case "AuthenticationFailed":
|
|
159
|
+
case "AuthorizationFailure":
|
|
160
|
+
case "AuthorizationPermissionMismatch":
|
|
161
|
+
case "AuthorizationResourceTypeMismatch":
|
|
162
|
+
case "NoAuthenticationInformation":
|
|
163
|
+
case "InvalidAuthenticationInfo":
|
|
164
|
+
return "auth-failed";
|
|
165
|
+
case "RequestBodyTooLarge":
|
|
166
|
+
case "BlockCountExceedsLimit":
|
|
167
|
+
return "size-exceeded";
|
|
168
|
+
case "BlobAlreadyExists":
|
|
169
|
+
return "conflict";
|
|
170
|
+
case "ServerBusy":
|
|
171
|
+
case "TooManyRequests":
|
|
172
|
+
return "rate-limited";
|
|
173
|
+
}
|
|
174
|
+
if (status === 429) return "rate-limited";
|
|
175
|
+
if (status === 401 || status === 403) return "auth-failed";
|
|
176
|
+
if (status === 404) return "not-found";
|
|
177
|
+
if (status === 413) return "size-exceeded";
|
|
178
|
+
return "provider-error";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// src/index.ts
|
|
182
|
+
var randomUUID = () => globalThis.crypto.randomUUID();
|
|
183
|
+
var PROVIDER = "azure";
|
|
184
|
+
var DEFAULT_BLOCK_SIZE = 8 * 1024 * 1024;
|
|
185
|
+
var MIN_BLOCK_SIZE = 1024 * 1024;
|
|
186
|
+
var MAX_BLOCK_SIZE = 4e3 * 1024 * 1024;
|
|
187
|
+
var MAX_BLOCKS = 5e4;
|
|
188
|
+
var PRESIGN_DEFAULT_EXPIRY = 3600;
|
|
189
|
+
var PRESIGN_MIN_EXPIRY = 1;
|
|
190
|
+
var PRESIGN_MAX_EXPIRY = 604800;
|
|
191
|
+
var TOKEN_KEY = "fileway_upload_token";
|
|
192
|
+
var TOKEN_HEADER = `x-ms-meta-${TOKEN_KEY}`;
|
|
193
|
+
var METADATA_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
194
|
+
var AzureDriver = class {
|
|
195
|
+
name = "azure";
|
|
196
|
+
container;
|
|
197
|
+
auth;
|
|
198
|
+
endpoint;
|
|
199
|
+
baseUrl;
|
|
200
|
+
blockSize;
|
|
201
|
+
maxSizeBytes;
|
|
202
|
+
constructor(config) {
|
|
203
|
+
const fromString = config.connectionString ? parseConnectionString(config.connectionString) : {};
|
|
204
|
+
const accountName = config.accountName ?? fromString.accountName;
|
|
205
|
+
const accountKey = config.accountKey ?? fromString.accountKey;
|
|
206
|
+
const sasToken = config.sasToken ?? fromString.sasToken;
|
|
207
|
+
const endpoint = config.endpoint ?? fromString.endpoint;
|
|
208
|
+
if (!config.container) {
|
|
209
|
+
throw new import_core.StorageError("config", "AzureDriver requires a container", { provider: PROVIDER });
|
|
210
|
+
}
|
|
211
|
+
const name = accountName ?? (endpoint ? accountFromEndpoint(endpoint) : void 0);
|
|
212
|
+
if (!name) {
|
|
213
|
+
throw new import_core.StorageError("config", "AzureDriver requires accountName (or a connection string or endpoint)", { provider: PROVIDER });
|
|
214
|
+
}
|
|
215
|
+
if (!accountKey && !sasToken) {
|
|
216
|
+
throw new import_core.StorageError("config", "AzureDriver requires accountKey, sasToken or a connection string with credentials", { provider: PROVIDER });
|
|
217
|
+
}
|
|
218
|
+
this.container = config.container;
|
|
219
|
+
this.auth = {
|
|
220
|
+
accountName: name,
|
|
221
|
+
...accountKey ? { accountKey } : {},
|
|
222
|
+
...sasToken ? { sasToken: sasToken.replace(/^\?/, "") } : {}
|
|
223
|
+
};
|
|
224
|
+
this.endpoint = (endpoint ?? `https://${name}.blob.core.windows.net`).replace(/\/+$/, "");
|
|
225
|
+
this.baseUrl = (config.baseUrl ?? `${this.endpoint}/${config.container}`).replace(/\/+$/, "");
|
|
226
|
+
this.blockSize = Math.min(Math.max(config.blockSize ?? DEFAULT_BLOCK_SIZE, MIN_BLOCK_SIZE), MAX_BLOCK_SIZE);
|
|
227
|
+
this.maxSizeBytes = config.maxSizeBytes;
|
|
228
|
+
}
|
|
229
|
+
async upload(stream, options, context) {
|
|
230
|
+
(0, import_core.validateUploadOptions)(options);
|
|
231
|
+
for (const key of Object.keys(options.metadata ?? {})) {
|
|
232
|
+
if (!METADATA_NAME.test(key)) {
|
|
233
|
+
throw new import_core.ValidationError(
|
|
234
|
+
`Azure metadata keys must be letters, digits and underscores, not starting with a digit (got "${key}")`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (this.maxSizeBytes !== void 0 && options.size !== void 0 && options.size > this.maxSizeBytes) {
|
|
239
|
+
throw new import_core.StorageError("size-exceeded", `file size ${options.size} exceeds maxSizeBytes ${this.maxSizeBytes}`, { provider: PROVIDER });
|
|
240
|
+
}
|
|
241
|
+
const id = options.id ?? randomUUID();
|
|
242
|
+
const path = this.pathFor({ ...options, id });
|
|
243
|
+
const contentType = options.mimeType ?? "application/octet-stream";
|
|
244
|
+
const metaHeaders = {
|
|
245
|
+
...Object.fromEntries(Object.entries(options.metadata ?? {}).map(([k, v]) => [`x-ms-meta-${k}`, v])),
|
|
246
|
+
...context?.uploadToken ? { [TOKEN_HEADER]: context.uploadToken } : {}
|
|
247
|
+
};
|
|
248
|
+
const signal = options.signal;
|
|
249
|
+
const reader = stream.getReader();
|
|
250
|
+
let aborted = signal?.aborted ?? false;
|
|
251
|
+
const onAbort = () => {
|
|
252
|
+
aborted = true;
|
|
253
|
+
reader.cancel().catch(() => {
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
if (signal && !aborted) signal.addEventListener("abort", onAbort, { once: true });
|
|
257
|
+
const ensureNotAborted = () => {
|
|
258
|
+
if (aborted) throw (0, import_core.abortError)("upload aborted");
|
|
259
|
+
};
|
|
260
|
+
let buffer = [];
|
|
261
|
+
let bufferedLen = 0;
|
|
262
|
+
let totalBytes = 0;
|
|
263
|
+
const blockIds = [];
|
|
264
|
+
const putBlock = async (bytes) => {
|
|
265
|
+
if (blockIds.length >= MAX_BLOCKS) {
|
|
266
|
+
throw new import_core.StorageError("size-exceeded", `upload exceeds ${MAX_BLOCKS} blocks; raise blockSize`, { provider: PROVIDER });
|
|
267
|
+
}
|
|
268
|
+
const blockId = bytesToBase64(new TextEncoder().encode(`fileway-${String(blockIds.length).padStart(6, "0")}`));
|
|
269
|
+
const res = await this.request("PUT", path, {
|
|
270
|
+
query: { comp: "block", blockid: blockId },
|
|
271
|
+
body: bytes,
|
|
272
|
+
...signal ? { signal } : {}
|
|
273
|
+
});
|
|
274
|
+
if (!res.ok) throw await azureError(`Azure put block ${blockIds.length + 1} failed`, res);
|
|
275
|
+
blockIds.push(blockId);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
while (true) {
|
|
279
|
+
const { done, value } = await reader.read();
|
|
280
|
+
ensureNotAborted();
|
|
281
|
+
if (done) break;
|
|
282
|
+
totalBytes += value.byteLength;
|
|
283
|
+
if (this.maxSizeBytes !== void 0 && totalBytes > this.maxSizeBytes) {
|
|
284
|
+
await reader.cancel().catch(() => {
|
|
285
|
+
});
|
|
286
|
+
throw new import_core.StorageError("size-exceeded", `upload exceeds maxSizeBytes ${this.maxSizeBytes}`, { provider: PROVIDER });
|
|
287
|
+
}
|
|
288
|
+
buffer.push(value);
|
|
289
|
+
bufferedLen += value.byteLength;
|
|
290
|
+
while (bufferedLen >= this.blockSize) {
|
|
291
|
+
const merged = concatBytes(buffer, bufferedLen);
|
|
292
|
+
await putBlock(merged.subarray(0, this.blockSize));
|
|
293
|
+
const rest = merged.subarray(this.blockSize);
|
|
294
|
+
buffer = rest.byteLength > 0 ? [rest] : [];
|
|
295
|
+
bufferedLen = rest.byteLength;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (options.size !== void 0 && totalBytes !== options.size) {
|
|
299
|
+
throw new import_core.ValidationError(`stream delivered ${totalBytes} bytes, expected ${options.size}`);
|
|
300
|
+
}
|
|
301
|
+
let res;
|
|
302
|
+
if (blockIds.length === 0) {
|
|
303
|
+
res = await this.request("PUT", path, {
|
|
304
|
+
headers: { "x-ms-blob-type": "BlockBlob", "content-type": contentType, ...metaHeaders },
|
|
305
|
+
body: concatBytes(buffer, bufferedLen),
|
|
306
|
+
...signal ? { signal } : {}
|
|
307
|
+
});
|
|
308
|
+
if (!res.ok) throw await azureError("Azure upload failed", res);
|
|
309
|
+
} else {
|
|
310
|
+
if (bufferedLen > 0) await putBlock(concatBytes(buffer, bufferedLen));
|
|
311
|
+
ensureNotAborted();
|
|
312
|
+
const xml = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((b) => `<Latest>${b}</Latest>`).join("")}</BlockList>`;
|
|
313
|
+
res = await this.request("PUT", path, {
|
|
314
|
+
query: { comp: "blocklist" },
|
|
315
|
+
headers: { "content-type": "application/xml", "x-ms-blob-content-type": contentType, ...metaHeaders },
|
|
316
|
+
body: new TextEncoder().encode(xml),
|
|
317
|
+
...signal ? { signal } : {}
|
|
318
|
+
});
|
|
319
|
+
if (!res.ok) throw await azureError("Azure put block list failed", res);
|
|
320
|
+
}
|
|
321
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
322
|
+
return {
|
|
323
|
+
id,
|
|
324
|
+
url: `${this.baseUrl}/${(0, import_core.urlEncodePath)(path)}`,
|
|
325
|
+
path,
|
|
326
|
+
size: totalBytes,
|
|
327
|
+
meta: { container: this.container, ...etag ? { etag } : {} }
|
|
328
|
+
};
|
|
329
|
+
} catch (err) {
|
|
330
|
+
reader.cancel().catch(() => {
|
|
331
|
+
});
|
|
332
|
+
throw err;
|
|
333
|
+
} finally {
|
|
334
|
+
signal?.removeEventListener("abort", onAbort);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Deletes the blob; a missing blob is not an error. */
|
|
338
|
+
async delete(path, options) {
|
|
339
|
+
const res = await this.request("DELETE", path, withSignal(options));
|
|
340
|
+
if (res.status === 404 && errorCode(res) !== "ContainerNotFound") return;
|
|
341
|
+
if (!res.ok) throw await azureError("Azure delete failed", res);
|
|
342
|
+
}
|
|
343
|
+
async getUrl(path, _options) {
|
|
344
|
+
return `${this.baseUrl}/${(0, import_core.urlEncodePath)(path)}`;
|
|
345
|
+
}
|
|
346
|
+
/** Streams the blob. `options.signal` also cancels reading the body. */
|
|
347
|
+
async get(path, options) {
|
|
348
|
+
const res = await this.request("GET", path, withSignal(options));
|
|
349
|
+
if (!res.ok) throw await azureError("Azure get failed", res);
|
|
350
|
+
if (!res.body) {
|
|
351
|
+
throw new import_core.StorageError("provider-error", "Azure get returned no response body", { provider: PROVIDER });
|
|
352
|
+
}
|
|
353
|
+
return res.body;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A read-only service SAS URL for `path`, valid for `expiresIn` seconds
|
|
357
|
+
* (integer in 1..604800, default 3600). Needs an account key.
|
|
358
|
+
*/
|
|
359
|
+
async getPresignedUrl(path, options) {
|
|
360
|
+
const expiresIn = options?.expiresIn ?? PRESIGN_DEFAULT_EXPIRY;
|
|
361
|
+
if (!Number.isInteger(expiresIn) || expiresIn < PRESIGN_MIN_EXPIRY || expiresIn > PRESIGN_MAX_EXPIRY) {
|
|
362
|
+
throw new import_core.ValidationError(
|
|
363
|
+
`expiresIn must be an integer between ${PRESIGN_MIN_EXPIRY} and ${PRESIGN_MAX_EXPIRY} seconds`
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
const { accountName, accountKey } = this.auth;
|
|
367
|
+
if (!accountKey) {
|
|
368
|
+
throw new import_core.StorageError("config", "AzureDriver needs accountKey to create presigned URLs", { provider: PROVIDER });
|
|
369
|
+
}
|
|
370
|
+
const sas = await blobReadSas(accountName, accountKey, this.container, path, new Date(Date.now() + expiresIn * 1e3));
|
|
371
|
+
return `${this.blobUrl(path)}?${sas}`;
|
|
372
|
+
}
|
|
373
|
+
/** The blob name an upload with `options.id` is stored under: `<path>/<id>.<ext>`. */
|
|
374
|
+
pathFor(options) {
|
|
375
|
+
const ext = options.filename.includes(".") ? options.filename.split(".").pop() : "";
|
|
376
|
+
const filename = ext ? `${options.id}.${ext}` : options.id;
|
|
377
|
+
return options.path ? `${options.path}/${filename}` : filename;
|
|
378
|
+
}
|
|
379
|
+
/** The blob at `path` (a `HEAD` request), or `null` if it does not exist. */
|
|
380
|
+
async head(path, options) {
|
|
381
|
+
const res = await this.request("HEAD", path, withSignal(options));
|
|
382
|
+
if (res.status === 404 && errorCode(res) !== "ContainerNotFound") return null;
|
|
383
|
+
if (!res.ok) throw await azureError("Azure head failed", res);
|
|
384
|
+
const etag = res.headers.get("etag") ?? void 0;
|
|
385
|
+
const token = res.headers.get(TOKEN_HEADER) ?? void 0;
|
|
386
|
+
const modified = res.headers.get("last-modified");
|
|
387
|
+
const contentType = res.headers.get("content-type") ?? void 0;
|
|
388
|
+
const name = path.split("/").pop() ?? path;
|
|
389
|
+
return {
|
|
390
|
+
id: name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name,
|
|
391
|
+
url: `${this.baseUrl}/${(0, import_core.urlEncodePath)(path)}`,
|
|
392
|
+
path,
|
|
393
|
+
size: Number(res.headers.get("content-length") ?? 0),
|
|
394
|
+
meta: { container: this.container, ...etag ? { etag } : {} },
|
|
395
|
+
...token ? { uploadToken: token } : {},
|
|
396
|
+
...modified ? { lastModified: new Date(modified) } : {},
|
|
397
|
+
...contentType ? { contentType } : {}
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/** Whether blocks have been staged for `path` but not yet committed. */
|
|
401
|
+
async isPending(path, options) {
|
|
402
|
+
const res = await this.request("GET", path, {
|
|
403
|
+
query: { comp: "blocklist", blocklisttype: "uncommitted" },
|
|
404
|
+
...withSignal(options)
|
|
405
|
+
});
|
|
406
|
+
if (res.status === 404 && errorCode(res) !== "ContainerNotFound") return false;
|
|
407
|
+
const body = await res.text().catch(() => "");
|
|
408
|
+
if (!res.ok) throw azureErrorFrom("Azure get block list failed", res, body);
|
|
409
|
+
const uncommitted = body.match(/<UncommittedBlocks>(.*?)<\/UncommittedBlocks>/s)?.[1] ?? "";
|
|
410
|
+
return uncommitted.includes("<Block>");
|
|
411
|
+
}
|
|
412
|
+
/** One page of the blobs under `options.prefix` (List Blobs). */
|
|
413
|
+
async list(options) {
|
|
414
|
+
const res = await this.request("GET", "", {
|
|
415
|
+
query: {
|
|
416
|
+
restype: "container",
|
|
417
|
+
comp: "list",
|
|
418
|
+
prefix: options?.prefix ?? "",
|
|
419
|
+
maxresults: String(options?.limit ?? 100),
|
|
420
|
+
...options?.recursive ? {} : { delimiter: "/" },
|
|
421
|
+
...options?.cursor !== void 0 ? { marker: (0, import_core.decodeCursor)(options.cursor) } : {}
|
|
422
|
+
},
|
|
423
|
+
...withSignal(options)
|
|
424
|
+
});
|
|
425
|
+
const body = await res.text().catch(() => "");
|
|
426
|
+
if (!res.ok) throw azureErrorFrom("Azure list failed", res, body);
|
|
427
|
+
const objects = xmlBlocks(body, "Blob").map((blob) => {
|
|
428
|
+
const modified = xmlValue(blob, "Last-Modified");
|
|
429
|
+
const etag = xmlValue(blob, "Etag");
|
|
430
|
+
const type = xmlValue(blob, "Content-Type");
|
|
431
|
+
return {
|
|
432
|
+
path: unescapeXml(xmlValue(blob, "Name") ?? ""),
|
|
433
|
+
size: Number(xmlValue(blob, "Content-Length") ?? 0),
|
|
434
|
+
...modified ? { lastModified: new Date(modified) } : {},
|
|
435
|
+
...etag ? { etag } : {},
|
|
436
|
+
...type ? { contentType: unescapeXml(type) } : {}
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
const folders = xmlBlocks(body, "BlobPrefix").map((p) => unescapeXml(xmlValue(p, "Name") ?? ""));
|
|
440
|
+
const next = xmlValue(body, "NextMarker");
|
|
441
|
+
return { objects, folders, ...next ? { cursor: (0, import_core.encodeCursor)(next) } : {} };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Copies a blob in this container (Copy Blob), waiting for it to finish when
|
|
445
|
+
* the service copies in the background. With `overwrite: false` the copy is
|
|
446
|
+
* made only if `to` does not exist (`If-None-Match: *`), atomically.
|
|
447
|
+
*/
|
|
448
|
+
async copy(from, to, options) {
|
|
449
|
+
const source = new URL(this.blobUrl(from));
|
|
450
|
+
if (this.auth.sasToken && !this.auth.accountKey) {
|
|
451
|
+
for (const [k, v] of new URLSearchParams(this.auth.sasToken)) source.searchParams.set(k, v);
|
|
452
|
+
}
|
|
453
|
+
const res = await this.request("PUT", to, {
|
|
454
|
+
headers: { "x-ms-copy-source": source.href, ...options?.overwrite ? {} : { "if-none-match": "*" } },
|
|
455
|
+
...withSignal(options)
|
|
456
|
+
});
|
|
457
|
+
if (!res.ok) throw await azureError("Azure copy failed", res);
|
|
458
|
+
let status = res.headers.get("x-ms-copy-status");
|
|
459
|
+
for (let wait = 200; status === "pending"; wait = Math.min(wait * 2, 2e3)) {
|
|
460
|
+
await delay(wait, options?.signal);
|
|
461
|
+
const head = await this.request("HEAD", to, withSignal(options));
|
|
462
|
+
if (!head.ok) throw await azureError("Azure copy status check failed", head);
|
|
463
|
+
status = head.headers.get("x-ms-copy-status");
|
|
464
|
+
if (status === "failed" || status === "aborted") {
|
|
465
|
+
throw new import_core.StorageError(
|
|
466
|
+
"provider-error",
|
|
467
|
+
`Azure copy ${status}: ${head.headers.get("x-ms-copy-status-description") ?? "no details"}`,
|
|
468
|
+
{ provider: PROVIDER }
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** Copies, then deletes the source. Blob Storage has no rename. */
|
|
474
|
+
async move(from, to, options) {
|
|
475
|
+
await this.copy(from, to, options);
|
|
476
|
+
await this.delete(from, options);
|
|
477
|
+
}
|
|
478
|
+
blobUrl(path) {
|
|
479
|
+
return `${this.endpoint}/${this.container}/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
480
|
+
}
|
|
481
|
+
/** A signed (Shared Key) or SAS-authorized request to one blob. */
|
|
482
|
+
async request(method, path, init = {}) {
|
|
483
|
+
const url = new URL(path === "" ? `${this.endpoint}/${this.container}` : this.blobUrl(path));
|
|
484
|
+
for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v);
|
|
485
|
+
let headers;
|
|
486
|
+
const { accountName, accountKey, sasToken } = this.auth;
|
|
487
|
+
if (accountKey) {
|
|
488
|
+
headers = await signRequest(method, url, accountName, accountKey, init.headers ?? {}, init.body?.byteLength);
|
|
489
|
+
} else {
|
|
490
|
+
for (const [k, v] of new URLSearchParams(sasToken)) url.searchParams.set(k, v);
|
|
491
|
+
headers = {
|
|
492
|
+
...init.headers,
|
|
493
|
+
"x-ms-date": (/* @__PURE__ */ new Date()).toUTCString(),
|
|
494
|
+
"x-ms-version": SERVICE_VERSION
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
return fetchWithAbort(url, {
|
|
498
|
+
method,
|
|
499
|
+
headers,
|
|
500
|
+
...init.body ? { body: init.body } : {},
|
|
501
|
+
signal: init.signal ?? null
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
function xmlBlocks(xml, tag) {
|
|
506
|
+
return [...xml.matchAll(new RegExp(`<${tag}>(.*?)</${tag}>`, "gs"))].map((m) => m[1] ?? "");
|
|
507
|
+
}
|
|
508
|
+
function xmlValue(xml, tag) {
|
|
509
|
+
return xml.match(new RegExp(`<${tag}>(.*?)</${tag}>`, "s"))?.[1] || void 0;
|
|
510
|
+
}
|
|
511
|
+
function unescapeXml(value) {
|
|
512
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&");
|
|
513
|
+
}
|
|
514
|
+
function delay(ms, signal) {
|
|
515
|
+
return new Promise((resolve, reject) => {
|
|
516
|
+
if (signal?.aborted) return reject((0, import_core.abortError)("copy aborted"));
|
|
517
|
+
const timer = setTimeout(() => {
|
|
518
|
+
signal?.removeEventListener("abort", onAbort);
|
|
519
|
+
resolve();
|
|
520
|
+
}, ms);
|
|
521
|
+
const onAbort = () => {
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
reject((0, import_core.abortError)("copy aborted"));
|
|
524
|
+
};
|
|
525
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
function withSignal(options) {
|
|
529
|
+
return options?.signal ? { signal: options.signal } : {};
|
|
530
|
+
}
|
|
531
|
+
function accountFromEndpoint(endpoint) {
|
|
532
|
+
const url = new URL(endpoint);
|
|
533
|
+
const fromHost = url.hostname.match(/^([a-z0-9]+)\.blob\./)?.[1];
|
|
534
|
+
return fromHost ?? (url.pathname.split("/").filter(Boolean)[0] || void 0);
|
|
535
|
+
}
|
|
536
|
+
async function fetchWithAbort(url, init) {
|
|
537
|
+
try {
|
|
538
|
+
return await fetch(url, init);
|
|
539
|
+
} catch (err) {
|
|
540
|
+
if ((0, import_core.isAbortError)(err)) throw err;
|
|
541
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
542
|
+
throw new import_core.StorageError("network", `${PROVIDER} network error: ${cause.message}`, { provider: PROVIDER, cause });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function errorCode(res, body) {
|
|
546
|
+
return res.headers.get("x-ms-error-code") ?? body?.match(/<Code>(.*?)<\/Code>/s)?.[1];
|
|
547
|
+
}
|
|
548
|
+
function azureErrorFrom(prefix, res, body) {
|
|
549
|
+
const code = errorCode(res, body);
|
|
550
|
+
return new import_core.StorageError(
|
|
551
|
+
classifyAzureError(res.status, code),
|
|
552
|
+
`${prefix}: ${res.status} ${res.statusText}${code ? ` (${code})` : ""}`,
|
|
553
|
+
{ statusCode: res.status, provider: PROVIDER }
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
async function azureError(prefix, res) {
|
|
557
|
+
const body = await res.text().catch(() => "");
|
|
558
|
+
return azureErrorFrom(prefix, res, body);
|
|
559
|
+
}
|
|
560
|
+
function concatBytes(chunks, total) {
|
|
561
|
+
const out = new Uint8Array(total);
|
|
562
|
+
let offset = 0;
|
|
563
|
+
for (const chunk of chunks) {
|
|
564
|
+
out.set(chunk, offset);
|
|
565
|
+
offset += chunk.byteLength;
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
570
|
+
0 && (module.exports = {
|
|
571
|
+
AzureDriver
|
|
572
|
+
});
|
|
573
|
+
//# sourceMappingURL=index.cjs.map
|