partial-content 1.0.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- package/src/web.ts +1603 -0
package/package.json
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "partial-content",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "RFC-compliant HTTP file serving for any storage backend. Range requests (206), conditional caching (304/412), Content-Disposition, and ETag generation. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./s3": {
|
|
15
|
+
"types": "./dist/s3.d.ts",
|
|
16
|
+
"import": "./dist/s3.js",
|
|
17
|
+
"default": "./dist/s3.js"
|
|
18
|
+
},
|
|
19
|
+
"./web": {
|
|
20
|
+
"types": "./dist/web.d.ts",
|
|
21
|
+
"import": "./dist/web.js",
|
|
22
|
+
"default": "./dist/web.js"
|
|
23
|
+
},
|
|
24
|
+
"./fs": {
|
|
25
|
+
"types": "./dist/fs.d.ts",
|
|
26
|
+
"import": "./dist/fs.js",
|
|
27
|
+
"default": "./dist/fs.js"
|
|
28
|
+
},
|
|
29
|
+
"./node": {
|
|
30
|
+
"types": "./dist/node.d.ts",
|
|
31
|
+
"import": "./dist/node.js",
|
|
32
|
+
"default": "./dist/node.js"
|
|
33
|
+
},
|
|
34
|
+
"./hono": {
|
|
35
|
+
"types": "./dist/hono.d.ts",
|
|
36
|
+
"import": "./dist/hono.js",
|
|
37
|
+
"default": "./dist/hono.js"
|
|
38
|
+
},
|
|
39
|
+
"./gcs": {
|
|
40
|
+
"types": "./dist/gcs.d.ts",
|
|
41
|
+
"import": "./dist/gcs.js",
|
|
42
|
+
"default": "./dist/gcs.js"
|
|
43
|
+
},
|
|
44
|
+
"./azure": {
|
|
45
|
+
"types": "./dist/azure.d.ts",
|
|
46
|
+
"import": "./dist/azure.js",
|
|
47
|
+
"default": "./dist/azure.js"
|
|
48
|
+
},
|
|
49
|
+
"./r2": {
|
|
50
|
+
"types": "./dist/r2.d.ts",
|
|
51
|
+
"import": "./dist/r2.js",
|
|
52
|
+
"default": "./dist/r2.js"
|
|
53
|
+
},
|
|
54
|
+
"./mime": {
|
|
55
|
+
"types": "./dist/mime.d.ts",
|
|
56
|
+
"import": "./dist/mime.js",
|
|
57
|
+
"default": "./dist/mime.js"
|
|
58
|
+
},
|
|
59
|
+
"./http": {
|
|
60
|
+
"types": "./dist/http.d.ts",
|
|
61
|
+
"import": "./dist/http.js",
|
|
62
|
+
"default": "./dist/http.js"
|
|
63
|
+
},
|
|
64
|
+
"./memory": {
|
|
65
|
+
"types": "./dist/memory.d.ts",
|
|
66
|
+
"import": "./dist/memory.js",
|
|
67
|
+
"default": "./dist/memory.js"
|
|
68
|
+
},
|
|
69
|
+
"./package.json": "./package.json"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"dist",
|
|
73
|
+
"src",
|
|
74
|
+
"!src/__tests__",
|
|
75
|
+
"README.md",
|
|
76
|
+
"docs/DESIGN.md",
|
|
77
|
+
"CHANGELOG.md",
|
|
78
|
+
"SECURITY.md",
|
|
79
|
+
"LICENSE"
|
|
80
|
+
],
|
|
81
|
+
"publishConfig": {
|
|
82
|
+
"registry": "https://registry.npmjs.org",
|
|
83
|
+
"access": "public"
|
|
84
|
+
},
|
|
85
|
+
"sideEffects": false,
|
|
86
|
+
"keywords": [
|
|
87
|
+
"http",
|
|
88
|
+
"range-request",
|
|
89
|
+
"partial-content",
|
|
90
|
+
"byte-range",
|
|
91
|
+
"206",
|
|
92
|
+
"304",
|
|
93
|
+
"412",
|
|
94
|
+
"416",
|
|
95
|
+
"conditional-request",
|
|
96
|
+
"content-disposition",
|
|
97
|
+
"etag",
|
|
98
|
+
"etag-generation",
|
|
99
|
+
"s3",
|
|
100
|
+
"r2",
|
|
101
|
+
"gcs",
|
|
102
|
+
"azure-blob",
|
|
103
|
+
"object-storage",
|
|
104
|
+
"supabase-storage",
|
|
105
|
+
"presigned-url",
|
|
106
|
+
"hetzner",
|
|
107
|
+
"streaming",
|
|
108
|
+
"video-seeking",
|
|
109
|
+
"pdf",
|
|
110
|
+
"resume-download",
|
|
111
|
+
"file-serving",
|
|
112
|
+
"optimistic-concurrency",
|
|
113
|
+
"cloudflare-workers",
|
|
114
|
+
"bun",
|
|
115
|
+
"deno",
|
|
116
|
+
"hono",
|
|
117
|
+
"nextjs",
|
|
118
|
+
"express",
|
|
119
|
+
"fastify"
|
|
120
|
+
],
|
|
121
|
+
"scripts": {
|
|
122
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
123
|
+
"prepare": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
124
|
+
"test": "bun test --isolate --timeout 30000",
|
|
125
|
+
"test:mutation": "stryker run",
|
|
126
|
+
"test:mutation:ci": "node scripts/mutation-ci.mjs",
|
|
127
|
+
"prepublishOnly": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc && node -e \"const t=require('child_process').execSync('npm pack --dry-run --json',{encoding:'utf8'});const f=JSON.parse(t)[0].files.map(f=>f.path);const bad=f.filter(p=>/\\.(map|env|test|spec)/.test(p)&&!p.endsWith('.d.ts.map')&&!p.endsWith('.js.map'));if(bad.length){console.error('BLOCKED: tarball contains suspicious files:',bad);process.exit(1)}\"",
|
|
128
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
129
|
+
"bench": "npm run build && node bench/run.mjs",
|
|
130
|
+
"smoke": "npm run build && node smoke/smoke.mjs",
|
|
131
|
+
"check:exports": "npm run build && attw --pack . --profile esm-only",
|
|
132
|
+
"check:consumer-types": "npm run build && tsc --noEmit -p smoke/tsconfig.consumer.json",
|
|
133
|
+
"test:live": "bun test src/__tests__/s3-live.test.ts"
|
|
134
|
+
},
|
|
135
|
+
"license": "MIT",
|
|
136
|
+
"author": "Nordvec <oss@nordvec.com>",
|
|
137
|
+
"repository": {
|
|
138
|
+
"type": "git",
|
|
139
|
+
"url": "git+https://github.com/nordvec/partial-content.git"
|
|
140
|
+
},
|
|
141
|
+
"bugs": "https://github.com/nordvec/partial-content/issues",
|
|
142
|
+
"homepage": "https://github.com/nordvec/partial-content#readme",
|
|
143
|
+
"engines": {
|
|
144
|
+
"node": ">=20.0.0"
|
|
145
|
+
},
|
|
146
|
+
"peerDependencies": {
|
|
147
|
+
"@aws-sdk/client-s3": "^3.400.0",
|
|
148
|
+
"@aws-sdk/s3-request-presigner": "^3.400.0",
|
|
149
|
+
"@google-cloud/storage": "^7.0.0",
|
|
150
|
+
"@azure/storage-blob": "^12.0.0",
|
|
151
|
+
"hono": "^4.0.0"
|
|
152
|
+
},
|
|
153
|
+
"peerDependenciesMeta": {
|
|
154
|
+
"@aws-sdk/client-s3": {
|
|
155
|
+
"optional": true
|
|
156
|
+
},
|
|
157
|
+
"@aws-sdk/s3-request-presigner": {
|
|
158
|
+
"optional": true
|
|
159
|
+
},
|
|
160
|
+
"@google-cloud/storage": {
|
|
161
|
+
"optional": true
|
|
162
|
+
},
|
|
163
|
+
"@azure/storage-blob": {
|
|
164
|
+
"optional": true
|
|
165
|
+
},
|
|
166
|
+
"hono": {
|
|
167
|
+
"optional": true
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
"devDependencies": {
|
|
171
|
+
"@arethetypeswrong/cli": "^0.18.4",
|
|
172
|
+
"@aws-sdk/client-s3": "^3.1075.0",
|
|
173
|
+
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
|
174
|
+
"@stryker-mutator/core": "^9.6.1",
|
|
175
|
+
"@types/node": "^26.1.0",
|
|
176
|
+
"autocannon": "^8.0.0",
|
|
177
|
+
"fast-check": "^4.8.0",
|
|
178
|
+
"send": "^1.2.1",
|
|
179
|
+
"sirv": "^3.0.2",
|
|
180
|
+
"typescript": "^6.0.3"
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/azure.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Blob Storage ObjectStore adapter for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `@azure/storage-blob` to implement the {@link ObjectStore} interface.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { BlobServiceClient } from "@azure/storage-blob";
|
|
9
|
+
* import { azureStore } from "partial-content/azure";
|
|
10
|
+
*
|
|
11
|
+
* const client = BlobServiceClient.fromConnectionString(connectionString);
|
|
12
|
+
* const store = azureStore({
|
|
13
|
+
* containerClient: client.getContainerClient("documents"),
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
ObjectNotFoundError,
|
|
22
|
+
ObjectChangedError,
|
|
23
|
+
StoreUnavailableError,
|
|
24
|
+
classifyStoreRead,
|
|
25
|
+
nodeStreamToWeb,
|
|
26
|
+
guardStreamLength,
|
|
27
|
+
resolveServedRange,
|
|
28
|
+
parseRetryAfterSeconds,
|
|
29
|
+
isOpenEndedRange,
|
|
30
|
+
type ObjectStore,
|
|
31
|
+
type ObjectMetadata,
|
|
32
|
+
type ObjectStream,
|
|
33
|
+
type ParsedRange,
|
|
34
|
+
type StoreErrorClassifiers,
|
|
35
|
+
} from "./index.js";
|
|
36
|
+
|
|
37
|
+
// Re-export for convenience
|
|
38
|
+
export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
|
|
39
|
+
|
|
40
|
+
// ─── Azure Types ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Minimal Azure ContainerClient interface.
|
|
44
|
+
*
|
|
45
|
+
* Declared locally to avoid a hard dependency on `@azure/storage-blob`.
|
|
46
|
+
* Users who import `partial-content/azure` will have it installed as an
|
|
47
|
+
* optional peer dependency.
|
|
48
|
+
*/
|
|
49
|
+
interface AzureContainerClient {
|
|
50
|
+
getBlobClient(blobName: string): AzureBlobClient;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface AzureBlobClient {
|
|
54
|
+
getProperties(options?: { abortSignal?: AbortSignal }): Promise<AzureBlobProperties>;
|
|
55
|
+
download(
|
|
56
|
+
offset?: number,
|
|
57
|
+
count?: number,
|
|
58
|
+
options?: { conditions?: { ifMatch?: string }; abortSignal?: AbortSignal },
|
|
59
|
+
): Promise<AzureBlobDownloadResponse>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface AzureBlobProperties {
|
|
63
|
+
contentLength?: number;
|
|
64
|
+
etag?: string;
|
|
65
|
+
lastModified?: Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AzureBlobDownloadResponse {
|
|
69
|
+
readableStreamBody?: NodeJS.ReadableStream & AsyncIterable<Buffer> & { destroy?: () => void };
|
|
70
|
+
blobBody?: Promise<Blob>;
|
|
71
|
+
contentLength?: number;
|
|
72
|
+
/** Present on ranged downloads: "bytes 5-9/20". Carries the total size. */
|
|
73
|
+
contentRange?: string;
|
|
74
|
+
etag?: string;
|
|
75
|
+
lastModified?: Date;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export interface AzureStoreOptions {
|
|
81
|
+
/** Pre-configured Azure ContainerClient. */
|
|
82
|
+
containerClient: AzureContainerClient;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create an {@link ObjectStore} backed by an Azure Blob Storage container.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* import { BlobServiceClient } from "@azure/storage-blob";
|
|
93
|
+
* import { azureStore } from "partial-content/azure";
|
|
94
|
+
*
|
|
95
|
+
* const blobService = BlobServiceClient.fromConnectionString(conn);
|
|
96
|
+
* const store = azureStore({
|
|
97
|
+
* containerClient: blobService.getContainerClient("documents"),
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* const meta = await store.headObject("reports/q4.pdf");
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function azureStore(opts: AzureStoreOptions): ObjectStore {
|
|
104
|
+
const { containerClient } = opts;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
supportsRange: true,
|
|
108
|
+
// 206 bounds/total come from Azure's actual contentRange: the
|
|
109
|
+
// orchestrator may skip the validating HEAD for plain range requests.
|
|
110
|
+
authoritativeRange: true,
|
|
111
|
+
|
|
112
|
+
async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
|
|
113
|
+
opts?.signal?.throwIfAborted();
|
|
114
|
+
// Forward the signal so a client disconnect during the request/header
|
|
115
|
+
// phase cancels the Azure call (the pre-check only covers already-aborted;
|
|
116
|
+
// the body phase is covered by nodeStreamToWeb's signal wiring).
|
|
117
|
+
const props = await classifyStoreRead(key, () => containerClient.getBlobClient(key).getProperties({ abortSignal: opts?.signal }), azureClassifiers);
|
|
118
|
+
|
|
119
|
+
if (props.contentLength == null) {
|
|
120
|
+
throw new Error(`Azure getProperties returned no contentLength for ${key}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
contentLength: props.contentLength,
|
|
125
|
+
etag: props.etag,
|
|
126
|
+
lastModified: props.lastModified?.toUTCString(),
|
|
127
|
+
};
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async getObject(key: string, opts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string }): Promise<ObjectStream> {
|
|
131
|
+
const { range, signal, ifMatch } = opts ?? {};
|
|
132
|
+
signal?.throwIfAborted();
|
|
133
|
+
|
|
134
|
+
// Single round-trip: download() carries everything needed. For ranged
|
|
135
|
+
// reads the response's Content-Range ("bytes 5-9/20") supplies the
|
|
136
|
+
// total size; for full reads contentLength IS the total. One response
|
|
137
|
+
// also means one representation -- no metadata/body race to guard.
|
|
138
|
+
const offset = range ? range.start : 0;
|
|
139
|
+
// An open-ended fast-path range (OPEN_ENDED sentinel end) reads to the
|
|
140
|
+
// end of the blob: leave count undefined so download() streams the
|
|
141
|
+
// tail rather than requesting ~9e15 bytes.
|
|
142
|
+
const count = range && !isOpenEndedRange(range)
|
|
143
|
+
? (range.end - range.start + 1)
|
|
144
|
+
: undefined;
|
|
145
|
+
|
|
146
|
+
const response = await classifyStoreRead(key, () => containerClient.getBlobClient(key).download(
|
|
147
|
+
offset,
|
|
148
|
+
count,
|
|
149
|
+
{
|
|
150
|
+
// Pin the read to the validated representation: Azure rejects
|
|
151
|
+
// with 412 ConditionNotMet if the blob changed since HEAD.
|
|
152
|
+
...(ifMatch ? { conditions: { ifMatch } } : {}),
|
|
153
|
+
// Forward the signal so the request/header phase is cancellable too.
|
|
154
|
+
abortSignal: signal,
|
|
155
|
+
},
|
|
156
|
+
), azureClassifiers);
|
|
157
|
+
|
|
158
|
+
if (response.contentLength == null) {
|
|
159
|
+
destroyAzureDownload(response);
|
|
160
|
+
throw new Error(`Azure download returned no contentLength for ${key}`);
|
|
161
|
+
}
|
|
162
|
+
const contentLength = response.contentLength;
|
|
163
|
+
const resolved = response.contentRange
|
|
164
|
+
? resolveServedRange(response.contentRange)
|
|
165
|
+
: null;
|
|
166
|
+
// A Content-Range Azure emits but the resolver cannot parse means the
|
|
167
|
+
// byte accounting is untrustworthy: release the live socket and fail
|
|
168
|
+
// loudly rather than guess.
|
|
169
|
+
if (response.contentRange && !resolved) {
|
|
170
|
+
destroyAzureDownload(response);
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Azure returned unparseable Content-Range for ${key}: ${response.contentRange}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
// For a full read (no Content-Range) contentLength IS the total; a ranged
|
|
176
|
+
// read carries the resolver's honest total (undefined for `bytes a-b/*`).
|
|
177
|
+
const totalSize = resolved ? resolved.totalSize : contentLength;
|
|
178
|
+
|
|
179
|
+
// Azure SDK returns different body types depending on the environment
|
|
180
|
+
let webStream: ReadableStream<Uint8Array<ArrayBuffer>>;
|
|
181
|
+
if (response.readableStreamBody) {
|
|
182
|
+
// Node.js environment: use shared stream conversion
|
|
183
|
+
const nodeStream = response.readableStreamBody;
|
|
184
|
+
// nodeStreamToWeb auto-detects the stream's destroy() capability.
|
|
185
|
+
// expectedBytes guards a graceful short-read: the Azure SDK can end
|
|
186
|
+
// readableStreamBody cleanly when its in-stream retries are exhausted
|
|
187
|
+
// mid-download, which would otherwise under-run the committed
|
|
188
|
+
// Content-Length undetected.
|
|
189
|
+
webStream = nodeStreamToWeb(nodeStream, { signal, expectedBytes: contentLength });
|
|
190
|
+
} else if (response.blobBody) {
|
|
191
|
+
// Browser environment: guard the buffered blob's byte count against the
|
|
192
|
+
// committed length for parity with the Node path's expectedBytes check.
|
|
193
|
+
const blob = await response.blobBody;
|
|
194
|
+
webStream = guardStreamLength(blob.stream() as ReadableStream<Uint8Array>, contentLength);
|
|
195
|
+
} else {
|
|
196
|
+
throw new Error(`Azure download returned no body for ${key}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
body: webStream,
|
|
201
|
+
contentLength,
|
|
202
|
+
totalSize,
|
|
203
|
+
// The bounds Azure ACTUALLY served, from its response Content-Range.
|
|
204
|
+
range: resolved ? resolved.served : undefined,
|
|
205
|
+
etag: response.etag,
|
|
206
|
+
lastModified: response.lastModified?.toUTCString(),
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Azure `RestError`'s numeric HTTP status, when present. This is the
|
|
216
|
+
* AUTHORITATIVE classification signal: when a numeric `statusCode` exists, the
|
|
217
|
+
* classifiers key off it exactly and never fall through to message heuristics,
|
|
218
|
+
* so a `503 ServerBusy` whose message text happens to contain "412" (a request
|
|
219
|
+
* id or timestamp digit run) can never be misread as a 412. A present-but-
|
|
220
|
+
* non-numeric status returns `undefined` so the code/message fallback still runs.
|
|
221
|
+
*/
|
|
222
|
+
function azureStatusCode(err: unknown): number | undefined {
|
|
223
|
+
if (typeof err === "object" && err !== null && "statusCode" in err) {
|
|
224
|
+
const s = (err as { statusCode: unknown }).statusCode;
|
|
225
|
+
if (typeof s === "number") return s;
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Azure `RestError`'s stable error code (`BlobNotFound`, `ConditionNotMet`,
|
|
232
|
+
* `ServerBusy`, ...). Matched exactly, so unlike a message substring it cannot
|
|
233
|
+
* collide with arbitrary text in the error message.
|
|
234
|
+
*/
|
|
235
|
+
function azureErrorCode(err: unknown): string | undefined {
|
|
236
|
+
if (typeof err === "object" && err !== null && "code" in err) {
|
|
237
|
+
const c = (err as { code: unknown }).code;
|
|
238
|
+
if (typeof c === "string") return c;
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isAzureNotFound(err: unknown): boolean {
|
|
244
|
+
const status = azureStatusCode(err);
|
|
245
|
+
if (status !== undefined) return status === 404;
|
|
246
|
+
if (azureErrorCode(err) === "BlobNotFound") return true;
|
|
247
|
+
return err instanceof Error && err.name === "RestError" && err.message.includes("BlobNotFound");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Release Azure's live download socket before bailing out on a malformed
|
|
252
|
+
* response. `download()` resolves once headers arrive with an OPEN
|
|
253
|
+
* `readableStreamBody`; throwing before that stream is handed to
|
|
254
|
+
* `nodeStreamToWeb` would leak the socket (and its connection-pool slot)
|
|
255
|
+
* until GC. The browser `blobBody` path buffers in memory and needs no
|
|
256
|
+
* teardown.
|
|
257
|
+
*/
|
|
258
|
+
function destroyAzureDownload(response: AzureBlobDownloadResponse): void {
|
|
259
|
+
response.readableStreamBody?.destroy?.();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Check if an Azure error is a failed ifMatch condition (blob changed).
|
|
264
|
+
* The SDK surfaces this as a RestError with statusCode 412 (ConditionNotMet).
|
|
265
|
+
* A numeric status is authoritative; the `code`/message fallback only runs for
|
|
266
|
+
* status-less shapes, so a throttle carrying "412" in its message text is never
|
|
267
|
+
* misreported as a precondition failure.
|
|
268
|
+
*/
|
|
269
|
+
function isAzurePreconditionFailed(err: unknown): boolean {
|
|
270
|
+
const status = azureStatusCode(err);
|
|
271
|
+
if (status !== undefined) return status === 412;
|
|
272
|
+
if (azureErrorCode(err) === "ConditionNotMet") return true;
|
|
273
|
+
return err instanceof Error && err.name === "RestError" && err.message.includes("ConditionNotMet");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The back-off Azure advises on a throttle, parsed from the `Retry-After`
|
|
278
|
+
* response header (whole seconds; the HTTP-date form is ignored). Surfaced so
|
|
279
|
+
* the 503 echoes it and shared caches wait the advised interval.
|
|
280
|
+
*/
|
|
281
|
+
function azureRetryAfterSeconds(err: unknown): number | undefined {
|
|
282
|
+
const headers = (err as { response?: { headers?: { get?: (name: string) => string | undefined } } })
|
|
283
|
+
.response?.headers;
|
|
284
|
+
// The classifier runs on arbitrary (S3-compatible/proxy) error shapes, so
|
|
285
|
+
// harden the structural access: optional-call guards only null/undefined, but
|
|
286
|
+
// a truthy non-function `.get` would throw, and a throwing `.get` propagates
|
|
287
|
+
// -- either way the thrown error would replace the throttle and turn a 429/503
|
|
288
|
+
// into a 502. Gate on the type and swallow a throwing getter.
|
|
289
|
+
const get = headers?.get;
|
|
290
|
+
if (typeof get !== "function") return undefined;
|
|
291
|
+
let raw: string | undefined;
|
|
292
|
+
try {
|
|
293
|
+
raw = get.call(headers, "retry-after");
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
return parseRetryAfterSeconds(raw);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Whether an Azure error is a transient throttle/overload the client should
|
|
302
|
+
* retry (mapped to a 503, not a 502). Azure's retryable transients are
|
|
303
|
+
* `ServerBusy` (HTTP 503) and `OperationTimedOut` (HTTP **500**), so they are
|
|
304
|
+
* matched by ERROR CODE first: keying only off the numeric status would demote
|
|
305
|
+
* `OperationTimedOut` to a non-retryable 502 (500 is neither 429 nor 503). A
|
|
306
|
+
* numeric 429/503 still catches throttles that arrive without a code, and the
|
|
307
|
+
* message fallback (gated on a status-less shape) preserves the classifier's
|
|
308
|
+
* exclusivity -- a 503 whose message merely contains "412" is classified by
|
|
309
|
+
* code/status, never by substring. Returns the advised `Retry-After` when set.
|
|
310
|
+
*/
|
|
311
|
+
function isAzureThrottled(err: unknown): boolean | { retryAfterSeconds: number } {
|
|
312
|
+
const code = azureErrorCode(err);
|
|
313
|
+
const status = azureStatusCode(err);
|
|
314
|
+
const throttled =
|
|
315
|
+
code === "ServerBusy" || code === "OperationTimedOut"
|
|
316
|
+
|| status === 429 || status === 503
|
|
317
|
+
|| (status === undefined && err instanceof Error && err.name === "RestError"
|
|
318
|
+
&& (err.message.includes("ServerBusy") || err.message.includes("OperationTimedOut")));
|
|
319
|
+
if (!throttled) return false;
|
|
320
|
+
const retryAfterSeconds = azureRetryAfterSeconds(err);
|
|
321
|
+
return retryAfterSeconds !== undefined ? { retryAfterSeconds } : true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** The ordered error-classification set shared by getProperties and download. */
|
|
325
|
+
const azureClassifiers: StoreErrorClassifiers = {
|
|
326
|
+
notFound: isAzureNotFound,
|
|
327
|
+
changed: isAzurePreconditionFailed,
|
|
328
|
+
throttled: isAzureThrottled,
|
|
329
|
+
};
|