@superfunctions/storage-azure 0.1.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/README.md +56 -0
- package/dist/adapter.d.ts +10 -0
- package/dist/adapter.js +306 -0
- package/dist/adapter.test.d.ts +1 -0
- package/dist/adapter.test.js +248 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @superfunctions/storage-azure
|
|
2
|
+
|
|
3
|
+
Azure Blob Storage adapter for `@superfunctions/storage`.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @superfunctions/storage-azure
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createAzureStorageAdapter } from '@superfunctions/storage-azure';
|
|
15
|
+
|
|
16
|
+
const connectionStringAdapter = createAzureStorageAdapter({
|
|
17
|
+
containerName: 'my-container',
|
|
18
|
+
connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Or use account name and key
|
|
22
|
+
const accountKeyAdapter = createAzureStorageAdapter({
|
|
23
|
+
containerName: 'my-container',
|
|
24
|
+
accountName: 'myaccount',
|
|
25
|
+
accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Or use SAS token
|
|
29
|
+
const sasTokenAdapter = createAzureStorageAdapter({
|
|
30
|
+
containerName: 'my-container',
|
|
31
|
+
accountName: 'myaccount',
|
|
32
|
+
sasToken: process.env.AZURE_STORAGE_SAS_TOKEN,
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- ✅ Signed upload URLs (SAS tokens)
|
|
39
|
+
- ✅ Signed download URLs (SAS tokens)
|
|
40
|
+
- ✅ Multipart uploads (Azure block blobs)
|
|
41
|
+
- ✅ Proxy streaming upload
|
|
42
|
+
- ✅ Proxy streaming download
|
|
43
|
+
|
|
44
|
+
## Azure Block Blob Semantics
|
|
45
|
+
|
|
46
|
+
Azure Blob Storage uses "block blobs" with "block IDs" instead of S3-style multipart uploads. This adapter maps:
|
|
47
|
+
|
|
48
|
+
- **uploadId**: Azure commitment placeholder (client-generated)
|
|
49
|
+
- **partNumber**: Mapped to block ID (base64-encoded)
|
|
50
|
+
- **etag**: Block ID returned as the ETag for consistency
|
|
51
|
+
|
|
52
|
+
When completing a multipart upload, the adapter commits all blocks to finalize the blob.
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { StorageAdapter } from '@superfunctions/storage';
|
|
2
|
+
export interface AzureStorageConfig {
|
|
3
|
+
containerName: string;
|
|
4
|
+
connectionString?: string;
|
|
5
|
+
accountName?: string;
|
|
6
|
+
accountKey?: string;
|
|
7
|
+
sasToken?: string;
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function createAzureStorageAdapter(config: AzureStorageConfig): StorageAdapter;
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import { BlobServiceClient, StorageSharedKeyCredential, BlobSASPermissions, generateBlobSASQueryParameters, SASProtocol, } from '@azure/storage-blob';
|
|
4
|
+
import { assertMultipartParts, createNotFoundError, isNotFoundError, } from '@superfunctions/storage/internal/errors';
|
|
5
|
+
function toAzureUploadError(error) {
|
|
6
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
7
|
+
}
|
|
8
|
+
function normalizeSasToken(sasToken) {
|
|
9
|
+
return sasToken.replace(/^\?+/, '');
|
|
10
|
+
}
|
|
11
|
+
function encodeUploadId(key) {
|
|
12
|
+
return Buffer.from(JSON.stringify({
|
|
13
|
+
key,
|
|
14
|
+
sessionId: randomUUID(),
|
|
15
|
+
}), 'utf8').toString('base64');
|
|
16
|
+
}
|
|
17
|
+
function decodeUploadId(uploadId) {
|
|
18
|
+
const decoded = Buffer.from(uploadId, 'base64').toString('utf8');
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(decoded);
|
|
21
|
+
if (typeof parsed.key === 'string' &&
|
|
22
|
+
parsed.key.length > 0 &&
|
|
23
|
+
typeof parsed.sessionId === 'string' &&
|
|
24
|
+
parsed.sessionId.length > 0) {
|
|
25
|
+
return {
|
|
26
|
+
key: parsed.key,
|
|
27
|
+
sessionId: parsed.sessionId,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Fall through to support legacy uploadIds that were just base64(key).
|
|
33
|
+
}
|
|
34
|
+
if (decoded.length > 0) {
|
|
35
|
+
return {
|
|
36
|
+
key: decoded,
|
|
37
|
+
sessionId: uploadId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
throw new Error('Invalid Azure multipart uploadId');
|
|
41
|
+
}
|
|
42
|
+
function createContainerClient(config) {
|
|
43
|
+
const { containerName, connectionString, accountName, accountKey, sasToken, endpoint } = config;
|
|
44
|
+
if (connectionString) {
|
|
45
|
+
const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
|
|
46
|
+
const containerClient = blobServiceClient.getContainerClient(containerName);
|
|
47
|
+
// Extract account name and credential from connection string for SAS generation
|
|
48
|
+
const accountNameMatch = connectionString.match(/AccountName=([^;]+)/);
|
|
49
|
+
const accountKeyMatch = connectionString.match(/AccountKey=([^;]+)/);
|
|
50
|
+
const extractedAccountName = accountNameMatch?.[1];
|
|
51
|
+
const extractedAccountKey = accountKeyMatch?.[1];
|
|
52
|
+
const credential = extractedAccountName && extractedAccountKey
|
|
53
|
+
? new StorageSharedKeyCredential(extractedAccountName, extractedAccountKey)
|
|
54
|
+
: undefined;
|
|
55
|
+
return { containerClient, credential, accountName: extractedAccountName };
|
|
56
|
+
}
|
|
57
|
+
if (accountName && accountKey) {
|
|
58
|
+
const credential = new StorageSharedKeyCredential(accountName, accountKey);
|
|
59
|
+
const blobServiceClient = new BlobServiceClient(endpoint || `https://${accountName}.blob.core.windows.net`, credential);
|
|
60
|
+
const containerClient = blobServiceClient.getContainerClient(containerName);
|
|
61
|
+
return { containerClient, credential, accountName };
|
|
62
|
+
}
|
|
63
|
+
if (accountName && sasToken) {
|
|
64
|
+
const blobServiceClient = new BlobServiceClient(`${endpoint || `https://${accountName}.blob.core.windows.net`}?${normalizeSasToken(sasToken)}`);
|
|
65
|
+
const containerClient = blobServiceClient.getContainerClient(containerName);
|
|
66
|
+
return { containerClient, accountName };
|
|
67
|
+
}
|
|
68
|
+
throw new Error('Azure storage config requires either connectionString or (accountName + accountKey) or (accountName + sasToken)');
|
|
69
|
+
}
|
|
70
|
+
function generateBlockId(uploadId, partNumber) {
|
|
71
|
+
return createHash('sha256')
|
|
72
|
+
.update(`${uploadId}:${String(partNumber).padStart(6, '0')}`, 'utf8')
|
|
73
|
+
.digest('base64');
|
|
74
|
+
}
|
|
75
|
+
export function createAzureStorageAdapter(config) {
|
|
76
|
+
const { containerClient, credential, accountName } = createContainerClient(config);
|
|
77
|
+
const supportsSignedUrls = Boolean(credential && accountName);
|
|
78
|
+
const capabilities = {
|
|
79
|
+
signedUploadUrls: supportsSignedUrls,
|
|
80
|
+
signedDownloadUrls: supportsSignedUrls,
|
|
81
|
+
multipart: supportsSignedUrls,
|
|
82
|
+
proxyStreamingUpload: true,
|
|
83
|
+
proxyStreamingDownload: true,
|
|
84
|
+
};
|
|
85
|
+
// Stateless adapter: no activeUploads map
|
|
86
|
+
return {
|
|
87
|
+
name: 'azure',
|
|
88
|
+
capabilities,
|
|
89
|
+
async statObject(input) {
|
|
90
|
+
try {
|
|
91
|
+
const blobClient = containerClient.getBlobClient(input.key);
|
|
92
|
+
const properties = await blobClient.getProperties();
|
|
93
|
+
return {
|
|
94
|
+
key: input.key,
|
|
95
|
+
size: properties.contentLength ?? 0,
|
|
96
|
+
contentType: properties.contentType,
|
|
97
|
+
etag: properties.etag,
|
|
98
|
+
lastModifiedAt: properties.lastModified?.toISOString(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (isNotFoundError(err, { statusCode: 404, stringCodes: ['BlobNotFound'] })) {
|
|
103
|
+
throw createNotFoundError();
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
async deleteObject(input) {
|
|
109
|
+
const blobClient = containerClient.getBlobClient(input.key);
|
|
110
|
+
await blobClient.deleteIfExists();
|
|
111
|
+
},
|
|
112
|
+
async signUploadUrl(input) {
|
|
113
|
+
if (!credential || !accountName) {
|
|
114
|
+
throw new Error('SAS URL generation requires accountName and credential (accountKey)');
|
|
115
|
+
}
|
|
116
|
+
const blobClient = containerClient.getBlobClient(input.key);
|
|
117
|
+
const blockBlobClient = blobClient.getBlockBlobClient();
|
|
118
|
+
const startsOn = new Date();
|
|
119
|
+
const expiresOn = new Date(startsOn.getTime() + input.expiresInSeconds * 1000);
|
|
120
|
+
const permissions = BlobSASPermissions.parse('w'); // write
|
|
121
|
+
const sasToken = generateBlobSASQueryParameters({
|
|
122
|
+
containerName: config.containerName,
|
|
123
|
+
blobName: input.key,
|
|
124
|
+
permissions,
|
|
125
|
+
startsOn,
|
|
126
|
+
expiresOn,
|
|
127
|
+
protocol: SASProtocol.Https,
|
|
128
|
+
contentType: input.constraints?.contentType,
|
|
129
|
+
}, credential).toString();
|
|
130
|
+
const url = `${blockBlobClient.url}?${sasToken}`;
|
|
131
|
+
const headers = {
|
|
132
|
+
'x-ms-blob-type': 'BlockBlob',
|
|
133
|
+
};
|
|
134
|
+
if (input.constraints?.contentType) {
|
|
135
|
+
headers['x-ms-blob-content-type'] = input.constraints.contentType;
|
|
136
|
+
}
|
|
137
|
+
return { url, headers: Object.keys(headers).length > 0 ? headers : undefined };
|
|
138
|
+
},
|
|
139
|
+
async signDownloadUrl(input) {
|
|
140
|
+
if (!credential || !accountName) {
|
|
141
|
+
throw new Error('SAS URL generation requires accountName and credential (accountKey)');
|
|
142
|
+
}
|
|
143
|
+
const blobClient = containerClient.getBlobClient(input.key);
|
|
144
|
+
const startsOn = new Date();
|
|
145
|
+
const expiresOn = new Date(startsOn.getTime() + input.expiresInSeconds * 1000);
|
|
146
|
+
const permissions = BlobSASPermissions.parse('r'); // read
|
|
147
|
+
const sasToken = generateBlobSASQueryParameters({
|
|
148
|
+
containerName: config.containerName,
|
|
149
|
+
blobName: input.key,
|
|
150
|
+
permissions,
|
|
151
|
+
startsOn,
|
|
152
|
+
expiresOn,
|
|
153
|
+
protocol: SASProtocol.Https,
|
|
154
|
+
contentType: input.constraints?.responseContentType,
|
|
155
|
+
}, credential).toString();
|
|
156
|
+
const url = `${blobClient.url}?${sasToken}`;
|
|
157
|
+
return { url };
|
|
158
|
+
},
|
|
159
|
+
async createMultipartUpload(input) {
|
|
160
|
+
const uploadId = encodeUploadId(input.key);
|
|
161
|
+
return { uploadId };
|
|
162
|
+
},
|
|
163
|
+
async signMultipartUploadPartUrl(input) {
|
|
164
|
+
if (!credential || !accountName) {
|
|
165
|
+
throw new Error('SAS URL generation requires accountName and credential (accountKey)');
|
|
166
|
+
}
|
|
167
|
+
// Verify uploadId matches key (stateless check)
|
|
168
|
+
const decodedUpload = decodeUploadId(input.uploadId);
|
|
169
|
+
if (decodedUpload.key !== input.key) {
|
|
170
|
+
throw new Error(`Invalid uploadId for key: ${input.key}`);
|
|
171
|
+
}
|
|
172
|
+
const blockBlobClient = containerClient.getBlockBlobClient(input.key);
|
|
173
|
+
const blockId = generateBlockId(input.uploadId, input.partNumber);
|
|
174
|
+
const startsOn = new Date();
|
|
175
|
+
const expiresOn = new Date(startsOn.getTime() + input.expiresInSeconds * 1000);
|
|
176
|
+
const permissions = BlobSASPermissions.parse('w'); // write
|
|
177
|
+
const sasToken = generateBlobSASQueryParameters({
|
|
178
|
+
containerName: config.containerName,
|
|
179
|
+
blobName: input.key,
|
|
180
|
+
permissions,
|
|
181
|
+
startsOn,
|
|
182
|
+
expiresOn,
|
|
183
|
+
protocol: SASProtocol.Https,
|
|
184
|
+
}, credential).toString();
|
|
185
|
+
// The URL must include the blockid query parameter for PutBlock operation
|
|
186
|
+
const url = `${blockBlobClient.url}?comp=block&blockid=${encodeURIComponent(blockId)}&${sasToken}`;
|
|
187
|
+
return { url };
|
|
188
|
+
},
|
|
189
|
+
async completeMultipartUpload(input) {
|
|
190
|
+
assertMultipartParts(input.parts);
|
|
191
|
+
// Verify uploadId
|
|
192
|
+
const decodedUpload = decodeUploadId(input.uploadId);
|
|
193
|
+
if (decodedUpload.key !== input.key) {
|
|
194
|
+
throw new Error(`Invalid uploadId for key: ${input.key}`);
|
|
195
|
+
}
|
|
196
|
+
const blockBlobClient = containerClient.getBlockBlobClient(input.key);
|
|
197
|
+
// In Azure, the etag from each part is actually the blockId we generated
|
|
198
|
+
// Sort parts and commit blocks
|
|
199
|
+
const sortedParts = [...input.parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
200
|
+
const blockList = sortedParts.map((part) => generateBlockId(input.uploadId, part.partNumber));
|
|
201
|
+
await blockBlobClient.commitBlockList(blockList);
|
|
202
|
+
},
|
|
203
|
+
async abortMultipartUpload(input) {
|
|
204
|
+
// Azure automatically garbage-collects uncommitted blocks after 7 days
|
|
205
|
+
// No explicit cleanup needed for stateless tracking
|
|
206
|
+
},
|
|
207
|
+
async openUploadStream(input) {
|
|
208
|
+
const blockBlobClient = containerClient.getBlockBlobClient(input.key);
|
|
209
|
+
const stream = new PassThrough();
|
|
210
|
+
let uploadError;
|
|
211
|
+
stream.on('error', (error) => {
|
|
212
|
+
uploadError = toAzureUploadError(error);
|
|
213
|
+
});
|
|
214
|
+
const uploadPromise = blockBlobClient.uploadStream(stream, 4 * 1024 * 1024, 5, {
|
|
215
|
+
blobHTTPHeaders: {
|
|
216
|
+
blobContentType: input.contentType,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
void uploadPromise.catch((error) => {
|
|
220
|
+
uploadError = toAzureUploadError(error);
|
|
221
|
+
});
|
|
222
|
+
return new WritableStream({
|
|
223
|
+
async write(chunk) {
|
|
224
|
+
if (uploadError) {
|
|
225
|
+
throw uploadError;
|
|
226
|
+
}
|
|
227
|
+
const buf = chunk instanceof Uint8Array ? Buffer.from(chunk) : chunk;
|
|
228
|
+
if (stream.write(buf)) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
await new Promise((resolve, reject) => {
|
|
232
|
+
const cleanup = () => {
|
|
233
|
+
stream.off('drain', onDrain);
|
|
234
|
+
stream.off('error', onError);
|
|
235
|
+
};
|
|
236
|
+
const onDrain = () => {
|
|
237
|
+
cleanup();
|
|
238
|
+
resolve();
|
|
239
|
+
};
|
|
240
|
+
const onError = (err) => {
|
|
241
|
+
cleanup();
|
|
242
|
+
reject(err);
|
|
243
|
+
};
|
|
244
|
+
stream.once('drain', onDrain);
|
|
245
|
+
stream.once('error', onError);
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
async close() {
|
|
249
|
+
if (uploadError) {
|
|
250
|
+
throw uploadError;
|
|
251
|
+
}
|
|
252
|
+
stream.end();
|
|
253
|
+
await uploadPromise;
|
|
254
|
+
},
|
|
255
|
+
abort(reason) {
|
|
256
|
+
stream.destroy(reason instanceof Error ? reason : new Error(String(reason)));
|
|
257
|
+
void uploadPromise.catch(() => undefined);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
async openDownloadStream(input) {
|
|
262
|
+
const blobClient = containerClient.getBlobClient(input.key);
|
|
263
|
+
try {
|
|
264
|
+
const exists = await blobClient.exists();
|
|
265
|
+
if (!exists) {
|
|
266
|
+
const notFoundError = new Error('Object not found');
|
|
267
|
+
notFoundError.code = 'STORAGE_NOT_FOUND';
|
|
268
|
+
throw notFoundError;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (err instanceof Error && 'code' in err && err.code === 'STORAGE_NOT_FOUND') {
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
const downloadOptions = input.range
|
|
278
|
+
? { offset: input.range.start, count: input.range.endInclusive - input.range.start + 1 }
|
|
279
|
+
: undefined;
|
|
280
|
+
const response = await blobClient.download(downloadOptions?.offset, downloadOptions?.count);
|
|
281
|
+
if (!response.readableStreamBody) {
|
|
282
|
+
throw new Error('No readable stream in download response');
|
|
283
|
+
}
|
|
284
|
+
const nodeStream = response.readableStreamBody;
|
|
285
|
+
return new ReadableStream({
|
|
286
|
+
start(controller) {
|
|
287
|
+
nodeStream.on('data', (chunk) => {
|
|
288
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
289
|
+
controller.enqueue(new Uint8Array(buffer));
|
|
290
|
+
});
|
|
291
|
+
nodeStream.on('end', () => {
|
|
292
|
+
controller.close();
|
|
293
|
+
});
|
|
294
|
+
nodeStream.on('error', (err) => {
|
|
295
|
+
controller.error(err);
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
cancel() {
|
|
299
|
+
if ('destroy' in nodeStream && typeof nodeStream.destroy === 'function') {
|
|
300
|
+
nodeStream.destroy();
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createAzureStorageAdapter } from './adapter.js';
|
|
3
|
+
import { runConformanceTests } from '@superfunctions/storage';
|
|
4
|
+
describe('storage-azure adapter', () => {
|
|
5
|
+
describe('conformance', () => {
|
|
6
|
+
it('should pass conformance tests (method existence)', async () => {
|
|
7
|
+
const adapter = createAzureStorageAdapter({
|
|
8
|
+
containerName: 'test-container',
|
|
9
|
+
accountName: 'testaccount',
|
|
10
|
+
accountKey: 'dGVzdGtleQ==',
|
|
11
|
+
});
|
|
12
|
+
const result = await runConformanceTests(adapter);
|
|
13
|
+
expect(result.ok).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
it('should have correct capabilities', () => {
|
|
16
|
+
const adapter = createAzureStorageAdapter({
|
|
17
|
+
containerName: 'test-container',
|
|
18
|
+
accountName: 'testaccount',
|
|
19
|
+
accountKey: 'dGVzdGtleQ==',
|
|
20
|
+
});
|
|
21
|
+
expect(adapter.capabilities).toEqual({
|
|
22
|
+
signedUploadUrls: true,
|
|
23
|
+
signedDownloadUrls: true,
|
|
24
|
+
multipart: true,
|
|
25
|
+
proxyStreamingUpload: true,
|
|
26
|
+
proxyStreamingDownload: true,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('adapter creation', () => {
|
|
31
|
+
it('should create adapter with connection string', () => {
|
|
32
|
+
const adapter = createAzureStorageAdapter({
|
|
33
|
+
containerName: 'my-container',
|
|
34
|
+
connectionString: 'DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=dGVzdGtleQ==;EndpointSuffix=core.windows.net',
|
|
35
|
+
});
|
|
36
|
+
expect(adapter.name).toBe('azure');
|
|
37
|
+
});
|
|
38
|
+
it('should create adapter with account name and key', () => {
|
|
39
|
+
const adapter = createAzureStorageAdapter({
|
|
40
|
+
containerName: 'my-container',
|
|
41
|
+
accountName: 'testaccount',
|
|
42
|
+
accountKey: 'dGVzdGtleQ==',
|
|
43
|
+
});
|
|
44
|
+
expect(adapter.name).toBe('azure');
|
|
45
|
+
});
|
|
46
|
+
it('should create adapter with account name and SAS token', () => {
|
|
47
|
+
const adapter = createAzureStorageAdapter({
|
|
48
|
+
containerName: 'my-container',
|
|
49
|
+
accountName: 'testaccount',
|
|
50
|
+
sasToken: 'sv=2020-08-04&ss=b&srt=sco&sp=rwdlac&se=2025-01-01T00:00:00Z&st=2024-01-01T00:00:00Z&spr=https&sig=signature',
|
|
51
|
+
});
|
|
52
|
+
expect(adapter.name).toBe('azure');
|
|
53
|
+
});
|
|
54
|
+
it('should create adapter with custom endpoint', () => {
|
|
55
|
+
const adapter = createAzureStorageAdapter({
|
|
56
|
+
containerName: 'my-container',
|
|
57
|
+
accountName: 'testaccount',
|
|
58
|
+
accountKey: 'dGVzdGtleQ==',
|
|
59
|
+
endpoint: 'https://custom-endpoint.blob.core.windows.net',
|
|
60
|
+
});
|
|
61
|
+
expect(adapter.name).toBe('azure');
|
|
62
|
+
});
|
|
63
|
+
it('should throw error with incomplete config', () => {
|
|
64
|
+
expect(() => {
|
|
65
|
+
createAzureStorageAdapter({
|
|
66
|
+
containerName: 'my-container',
|
|
67
|
+
});
|
|
68
|
+
}).toThrow('Azure storage config requires either connectionString or (accountName + accountKey) or (accountName + sasToken)');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('multipart methods', () => {
|
|
72
|
+
it('should have multipart methods (Azure block blob semantics)', () => {
|
|
73
|
+
const adapter = createAzureStorageAdapter({
|
|
74
|
+
containerName: 'test-container',
|
|
75
|
+
accountName: 'testaccount',
|
|
76
|
+
accountKey: 'dGVzdGtleQ==',
|
|
77
|
+
});
|
|
78
|
+
expect(adapter.createMultipartUpload).toBeDefined();
|
|
79
|
+
expect(adapter.signMultipartUploadPartUrl).toBeDefined();
|
|
80
|
+
expect(adapter.completeMultipartUpload).toBeDefined();
|
|
81
|
+
expect(adapter.abortMultipartUpload).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
it('should generate stateless uploadId (base64 of key)', async () => {
|
|
84
|
+
const adapter = createAzureStorageAdapter({
|
|
85
|
+
containerName: 'test-container',
|
|
86
|
+
accountName: 'testaccount',
|
|
87
|
+
accountKey: 'dGVzdGtleQ==',
|
|
88
|
+
});
|
|
89
|
+
const key = 'test-file.bin';
|
|
90
|
+
const result = await adapter.createMultipartUpload({
|
|
91
|
+
key,
|
|
92
|
+
contentType: 'application/octet-stream',
|
|
93
|
+
});
|
|
94
|
+
const decoded = JSON.parse(Buffer.from(result.uploadId, 'base64').toString('utf8'));
|
|
95
|
+
expect(decoded.key).toBe(key);
|
|
96
|
+
expect(decoded.sessionId).toEqual(expect.any(String));
|
|
97
|
+
});
|
|
98
|
+
it('should generate unique uploadIds per multipart session for the same key', async () => {
|
|
99
|
+
const adapter = createAzureStorageAdapter({
|
|
100
|
+
containerName: 'test-container',
|
|
101
|
+
accountName: 'testaccount',
|
|
102
|
+
accountKey: 'dGVzdGtleQ==',
|
|
103
|
+
});
|
|
104
|
+
const first = await adapter.createMultipartUpload({ key: 'test-file.bin' });
|
|
105
|
+
const second = await adapter.createMultipartUpload({ key: 'test-file.bin' });
|
|
106
|
+
expect(first.uploadId).not.toBe(second.uploadId);
|
|
107
|
+
});
|
|
108
|
+
it('still accepts legacy base64(key) uploadIds even when the key is valid JSON', async () => {
|
|
109
|
+
const adapter = createAzureStorageAdapter({
|
|
110
|
+
containerName: 'test-container',
|
|
111
|
+
accountName: 'testaccount',
|
|
112
|
+
accountKey: 'dGVzdGtleQ==',
|
|
113
|
+
});
|
|
114
|
+
const key = 'null';
|
|
115
|
+
const legacyUploadId = Buffer.from(key).toString('base64');
|
|
116
|
+
const part = await adapter.signMultipartUploadPartUrl({
|
|
117
|
+
key,
|
|
118
|
+
uploadId: legacyUploadId,
|
|
119
|
+
partNumber: 1,
|
|
120
|
+
expiresInSeconds: 900,
|
|
121
|
+
});
|
|
122
|
+
expect(part.url).toContain('blockid=');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('signed URL methods', () => {
|
|
126
|
+
it('should have signed URL methods', () => {
|
|
127
|
+
const adapter = createAzureStorageAdapter({
|
|
128
|
+
containerName: 'test-container',
|
|
129
|
+
accountName: 'testaccount',
|
|
130
|
+
accountKey: 'dGVzdGtleQ==',
|
|
131
|
+
});
|
|
132
|
+
expect(adapter.signUploadUrl).toBeDefined();
|
|
133
|
+
expect(adapter.signDownloadUrl).toBeDefined();
|
|
134
|
+
});
|
|
135
|
+
it('should throw error when generating SAS without credentials', async () => {
|
|
136
|
+
const adapter = createAzureStorageAdapter({
|
|
137
|
+
containerName: 'test-container',
|
|
138
|
+
accountName: 'testaccount',
|
|
139
|
+
sasToken: 'existing-token',
|
|
140
|
+
});
|
|
141
|
+
await expect(async () => {
|
|
142
|
+
await adapter.signUploadUrl({
|
|
143
|
+
key: 'test.txt',
|
|
144
|
+
expiresInSeconds: 900,
|
|
145
|
+
});
|
|
146
|
+
}).rejects.toThrow('SAS URL generation requires accountName and credential (accountKey)');
|
|
147
|
+
});
|
|
148
|
+
it('returns the required blob type header for signed uploads', async () => {
|
|
149
|
+
const adapter = createAzureStorageAdapter({
|
|
150
|
+
containerName: 'test-container',
|
|
151
|
+
accountName: 'testaccount',
|
|
152
|
+
accountKey: 'dGVzdGtleQ==',
|
|
153
|
+
});
|
|
154
|
+
const result = await adapter.signUploadUrl({
|
|
155
|
+
key: 'test.txt',
|
|
156
|
+
expiresInSeconds: 900,
|
|
157
|
+
});
|
|
158
|
+
expect(result.headers).toMatchObject({
|
|
159
|
+
'x-ms-blob-type': 'BlockBlob',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
it('disables signed-url capabilities when only a SAS token is configured', () => {
|
|
163
|
+
const adapter = createAzureStorageAdapter({
|
|
164
|
+
containerName: 'test-container',
|
|
165
|
+
accountName: 'testaccount',
|
|
166
|
+
sasToken: '?existing-token',
|
|
167
|
+
});
|
|
168
|
+
expect(adapter.capabilities).toMatchObject({
|
|
169
|
+
signedUploadUrls: false,
|
|
170
|
+
signedDownloadUrls: false,
|
|
171
|
+
multipart: false,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe('proxy streaming methods', () => {
|
|
176
|
+
it('should have proxy streaming methods', () => {
|
|
177
|
+
const adapter = createAzureStorageAdapter({
|
|
178
|
+
containerName: 'test-container',
|
|
179
|
+
accountName: 'testaccount',
|
|
180
|
+
accountKey: 'dGVzdGtleQ==',
|
|
181
|
+
});
|
|
182
|
+
expect(adapter.openUploadStream).toBeDefined();
|
|
183
|
+
expect(adapter.openDownloadStream).toBeDefined();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('block blob semantics', () => {
|
|
187
|
+
it('should map part numbers to block IDs', async () => {
|
|
188
|
+
const adapter = createAzureStorageAdapter({
|
|
189
|
+
containerName: 'test-container',
|
|
190
|
+
accountName: 'testaccount',
|
|
191
|
+
accountKey: 'dGVzdGtleQ==',
|
|
192
|
+
});
|
|
193
|
+
const key = 'test-multipart.bin';
|
|
194
|
+
const { uploadId } = await adapter.createMultipartUpload({
|
|
195
|
+
key,
|
|
196
|
+
});
|
|
197
|
+
// Sign part 1
|
|
198
|
+
const part1 = await adapter.signMultipartUploadPartUrl({
|
|
199
|
+
key,
|
|
200
|
+
uploadId,
|
|
201
|
+
partNumber: 1,
|
|
202
|
+
expiresInSeconds: 900,
|
|
203
|
+
});
|
|
204
|
+
// URL should contain blockid parameter
|
|
205
|
+
expect(part1.url).toContain('comp=block');
|
|
206
|
+
expect(part1.url).toContain('blockid=');
|
|
207
|
+
});
|
|
208
|
+
it('TV-STORAGE-AZURE-STATELESS-001: should allow cross-instance multipart operations', async () => {
|
|
209
|
+
const config = {
|
|
210
|
+
containerName: 'test-container',
|
|
211
|
+
accountName: 'testaccount',
|
|
212
|
+
accountKey: 'dGVzdGtleQ==',
|
|
213
|
+
};
|
|
214
|
+
const adapterA = createAzureStorageAdapter(config);
|
|
215
|
+
const adapterB = createAzureStorageAdapter(config);
|
|
216
|
+
const key = 'stateless-test.bin';
|
|
217
|
+
// Create with A
|
|
218
|
+
const { uploadId } = await adapterA.createMultipartUpload({
|
|
219
|
+
key,
|
|
220
|
+
contentType: 'application/octet-stream',
|
|
221
|
+
});
|
|
222
|
+
// Sign with B
|
|
223
|
+
const part = await adapterB.signMultipartUploadPartUrl({
|
|
224
|
+
key,
|
|
225
|
+
uploadId,
|
|
226
|
+
partNumber: 1,
|
|
227
|
+
expiresInSeconds: 900,
|
|
228
|
+
});
|
|
229
|
+
expect(part.url).toBeDefined();
|
|
230
|
+
expect(part.url).toContain('blockid=');
|
|
231
|
+
});
|
|
232
|
+
it('should reject invalid uploadId', async () => {
|
|
233
|
+
const adapter = createAzureStorageAdapter({
|
|
234
|
+
containerName: 'test-container',
|
|
235
|
+
accountName: 'testaccount',
|
|
236
|
+
accountKey: 'dGVzdGtleQ==',
|
|
237
|
+
});
|
|
238
|
+
const key = 'test-file.bin';
|
|
239
|
+
const badUploadId = Buffer.from('wrong-key').toString('base64');
|
|
240
|
+
await expect(adapter.signMultipartUploadPartUrl({
|
|
241
|
+
key,
|
|
242
|
+
uploadId: badUploadId,
|
|
243
|
+
partNumber: 1,
|
|
244
|
+
expiresInSeconds: 900,
|
|
245
|
+
})).rejects.toThrow(`Invalid uploadId for key: ${key}`);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './adapter.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './adapter.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@superfunctions/storage-azure",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Azure Blob Storage adapter for @superfunctions/storage",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"test": "vitest",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"clean": "rm -rf dist"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["storage", "azure", "azure-blob-storage", "superfunctions"],
|
|
22
|
+
"author": "21n",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bugs": { "url": "https://github.com/21nCo/super-functions/issues" },
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/21nCo/super-functions.git",
|
|
28
|
+
"directory": "packages/storage-azure"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": { "access": "public" },
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@azure/storage-blob": "^12.18.0",
|
|
33
|
+
"@superfunctions/storage": "0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"typescript": "^5.6.0",
|
|
38
|
+
"vitest": "^3.2.4"
|
|
39
|
+
}
|
|
40
|
+
}
|