diff --git a/jest.config.js b/jest.config.js index cc3ea143..3457df94 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { moduleNameMapper: { "^@microsoft/durabletask-js$": "/packages/durabletask-js/src/index.ts", "^@microsoft/durabletask-js-azuremanaged$": "/packages/durabletask-js-azuremanaged/src/index.ts", + "^@microsoft/durabletask-js-azureblob$": "/packages/durabletask-js-azureblob/src/index.ts", "^@microsoft/durabletask-js-export-history$": "/packages/durabletask-js-export-history/src/index.ts", }, transform: { diff --git a/package-lock.json b/package-lock.json index 29223192..78789512 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1630,6 +1630,10 @@ "resolved": "packages/durabletask-js", "link": true }, + "node_modules/@microsoft/durabletask-js-azureblob": { + "resolved": "packages/durabletask-js-azureblob", + "link": true + }, "node_modules/@microsoft/durabletask-js-azuremanaged": { "resolved": "packages/durabletask-js-azuremanaged", "link": true @@ -7651,6 +7655,21 @@ } } }, + "packages/durabletask-js-azureblob": { + "name": "@microsoft/durabletask-js-azureblob", + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.0", + "@azure/storage-blob": "^12.26.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@microsoft/durabletask-js": ">=0.4.1" + } + }, "packages/durabletask-js-azuremanaged": { "name": "@microsoft/durabletask-js-azuremanaged", "version": "0.4.0", diff --git a/packages/durabletask-js-azureblob/CHANGELOG.md b/packages/durabletask-js-azureblob/CHANGELOG.md new file mode 100644 index 00000000..042a5025 --- /dev/null +++ b/packages/durabletask-js-azureblob/CHANGELOG.md @@ -0,0 +1,27 @@ +## Upcoming + +### Fixes + +- Remove the custom HTTP/response-stream transport adapter. Use native Azure SDK abort and + stream handling without injected progress callbacks or additional transport controllers; + active download cancellation no longer emits the adapter's unhandled AbortError, and socket + failures are no longer stranded behind its progress stream. +- Validate the opt-in Azurite test endpoint using the SDK-selected URL before any storage I/O. +- Keep account-specific secondary failover off cross-endpoint identity requests and reject a + final secondary 404 as unknown primary absence, preserving the actual SDK failure as its cause. +- Delegate HTTP retries entirely to the Azure JavaScript SDK, with nine total attempts, + 250 ms retry delay and a 10 s exponential cap by default. Remove custom retry classification, + backoff/jitter and Retry-After emulation; network behavior intentionally follows JavaScript + rather than exact .NET retry semantics. Preserve public caller options and SDK ETag-bound + body recovery without nested HTTP retries or whole-download retries. `tryTimeoutInMs` is + only the native server-side hint; no additional client network/read deadline is enforced. + +### New + +- Require core SDK `>=0.4.1`, excluding published `0.4.0`, which does not export the payload APIs. +- Add opt-in Azure Blob payload storage, interoperable with durabletask-dotnet + `91739687ff647699079cdadd627daa00562982b7`: UTF-8/gzip blobs, credential-free v2 tokens, + legacy v1 reads, safe same-service and identity-only cross-account routing. +- Support explicit ownership-checked, ETag-conditional deletion, including snapshots and + confirmed-absence handling. Scheduler-driven cleanup follows in later phases; do not release + partial feature phases. diff --git a/packages/durabletask-js-azureblob/LICENSE b/packages/durabletask-js-azureblob/LICENSE new file mode 100644 index 00000000..22aed37e --- /dev/null +++ b/packages/durabletask-js-azureblob/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/durabletask-js-azureblob/README.md b/packages/durabletask-js-azureblob/README.md new file mode 100644 index 00000000..05b95cf2 --- /dev/null +++ b/packages/durabletask-js-azureblob/README.md @@ -0,0 +1,156 @@ +# Azure Blob payload storage for Durable Task + +`@microsoft/durabletask-js-azureblob` adds opt-in external payload storage to the +Durable Task JavaScript SDK. Core users do not acquire Azure Storage dependencies. +Its tokens, UTF-8/gzip representation, and ownership metadata +match [durabletask-dotnet at `91739687`](https://github.com/microsoft/durabletask-dotnet/tree/91739687ff647699079cdadd627daa00562982b7/src/Extensions/AzureBlobPayloads). + +This is phase 3 of the six-PR feature stack. The provider works with the preceding SDK +transport integration; scheduler-driven automatic cleanup is implemented in later phases. +Do not publish a partial feature phase. + +## Configure clients and workers + +Requires `@microsoft/durabletask-js >=0.4.1`, the first core source version with +`PayloadStore` and the payload transport APIs. Published core `0.4.0` is incompatible; +npm rejects that peer pairing. Publish the payload-capable core release before this extension. + +```sh +npm install @microsoft/durabletask-js @microsoft/durabletask-js-azureblob +``` + +```typescript +import { TaskHubGrpcClient, TaskHubGrpcWorker } from "@microsoft/durabletask-js"; +import { BlobPayloadStore } from "@microsoft/durabletask-js-azureblob"; + +const store = new BlobPayloadStore({ + connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING!, + // containerName: "durabletask-payloads", + // compressionEnabled: true, +}); +const payloads = { store, thresholdBytes: 256 * 1024, maxPayloadBytes: 10 * 1024 * 1024 }; +const client = new TaskHubGrpcClient({ hostAddress: "localhost:8080", payloads }); +const worker = new TaskHubGrpcWorker({ hostAddress: "localhost:8080", payloads }); +``` + +For identity authentication, install `@azure/identity` and replace the store configuration: + +```typescript +import { DefaultAzureCredential } from "@azure/identity"; + +const store = new BlobPayloadStore({ + accountUri: "https://myaccount.blob.core.windows.net", + credential: new DefaultAzureCredential(), +}); +``` + +The identity needs permission to create the private container and read/write its blobs, +and delete permission when invoking the store's deletion API. The SDK does not provision accounts or roles. +Azure-managed scheduler builders expose `.useExternalizedPayloads(payloads)` on both +`DurableTaskAzureManagedClientBuilder` and `DurableTaskAzureManagedWorkerBuilder`. +Scheduler credentials and blob credentials are independent. + +Configure every participating client and worker, including .NET workers in a mixed-language +task hub, **before** writing externalized payloads. Workers advertise large-payload capability +only when enabled. Removing support while stored tokens remain makes those instances unreadable. +This feature targets the Durable Task Scheduler; it does not reconfigure a Durable Functions host. + +## Payload contract + +| Option | Default | Behavior | +| -------------------------- | ---------------------- | ----------------------------------------------------------------------------------------- | +| `payloads.thresholdBytes` | 262144 (256 KiB) | Externalize at **greater than or equal** serialized UTF-8 bytes; maximum threshold 1 MiB. | +| `payloads.maxPayloadBytes` | 10485760 (10 MiB) | Reject larger externalized payloads before uploading. | +| `containerName` | `durabletask-payloads` | Lazily create a private container, once per successful initialization. | +| `compressionEnabled` | `true` | Gzip payload bytes; record `Content-Encoding: gzip`. | + +Sizes refer to serialized values, including JSON quotes/escapes, not JavaScript string length +or compressed size. Absent and empty payloads remain inline. With the feature disabled, +existing transport and payload behavior are unchanged. + +The SDK transparently externalizes client start/event/entity-signal inputs and management +reasons/outputs, activity results, orchestration actions and custom status, continue-as-new +inputs, carried-over events, rewritten history, and entity state/results/outgoing operations. State queries, +waits, history streams, and orchestration/activity/entity V1/V2 work items are hydrated before +conversion or user execution. Each message runs at most eight payload operations concurrently; +failure/cancellation stops further dispatch and drains already-started operations. + +Every eligible payload's serialized UTF-8 size is validated across the whole message before +any upload starts. A deterministically oversized message therefore creates no blobs; valid +messages still use bounded I/O with cancellation and message-order storage failure handling. + +New blobs have 32-character GUID names and `managed_by=dts` metadata on compressed and +uncompressed writes. Tokens are `blob:v2:{absoluteBlobUrl}`, without account keys or SAS. +Readers also recognize `blob:v1:{container}:{blobName}` in the configured container. +Same-service v2 reads preserve the configured credential/pipeline across containers, +including path-style/Azurite endpoints. Another account or endpoint requires an explicit +`TokenCredential`; account keys and configured SAS credentials are never forwarded there. +Account-specific `secondaryHost` failover is not forwarded to a cross-endpoint identity client. + +The Azure Storage JavaScript SDK owns the only HTTP retry loop, including endpoint selection, +error classification and retry delays. The provider supplies defaults of nine total attempts, +`retryDelayInMs: 250` and `maxRetryDelayInMs: 10000`, and preserves SDK authentication, proxy, +user agent and custom HTTP transport options. Retry behavior deliberately follows the installed +JavaScript SDK, **not** the pinned .NET provider; payload tokens and storage representation remain +interoperable. + +For example, the tested Blob SDK 12.31.0 retries ordinary HTTP 500/503 and its recognized +transport/copy-source failures, but not ordinary 408/429/502/504. Its first exponential retry is +immediate; subsequent primary delays follow the SDK's exponential formula without .NET jitter +or Retry-After parsing. `FIXED` uses the SDK's configured delay. These are native SDK behaviors, +not additional provider policies. + +The SDK also owns HTTP sends, abort handling and response-stream lifetime. The provider passes +the caller's `AbortSignal` directly to storage and the Node pipeline that consumes/decompresses +the payload. It does not inject progress callbacks, wrap response streams, create transport +abort controllers or enforce a client-side network/pending-read deadline. + +`clientOptions.retryOptions` retains `maxTries` (total HTTP attempts, minimum 1), +`retryDelayInMs`, `maxRetryDelayInMs`, `retryPolicyType` (exponential/fixed), +`tryTimeoutInMs` (native server-side timeout hint, default 120000 ms, not a local deadline), +and `secondaryHost` (native same-account read failover). An explicit zero disables that hint. +Other SDK options, including caller-provided `httpClient`, are passed through unchanged. +Custom transports must honor the SDK's abort signal and return Node-readable bodies. +Caller cancellation preserves the original reason. SDK-native network/body errors propagate +without custom normalization or whole-download retries; native resumable failures can use up +to five additional ranged GETs with the original ETag/`If-Match`. Some errors, including native +body `AbortError`, are terminal rather than resumable. A failed ETag condition does not mix +payload versions, and corrupt gzip is not hidden by HTTP retries. + +A final secondary 404 is not confirmed primary absence: the provider checks the actual SDK error's request +endpoint and raises a retryable `RestError` with the original 404 as its cause, rather than +reporting missing/deleted data. It does not add attempts or fabricate an HTTP status. +An actual primary 404 still confirms absence, including after the SDK stops using a lagging +secondary. No provider retry loop multiplies the SDK budget. + +Missing blobs and store/size +failures surface as `PayloadStorageException`. Permanent activity/orchestrator response +upload failures become non-retriable task/orchestration failures; transient failures remain +retryable. Entity upload failures and download failures propagate rather than fabricating success. + +Externalization and backend submission are not a transaction. Later I/O failures, cancellation, +or uncertain submission can still leave unreferenced blobs; uploads are not automatically rolled +back. Tombstone-based cleanup requires a reference recorded by the backend and cannot discover +or recover existing orphan blobs. + +Custom stores can extend `PayloadStore` from the core package. They must honor `AbortSignal`, +recognize only their own tokens, and return confirmed `PayloadDeleteOutcome` values for cleanup. +Deletion is optional. The provider's explicit deletion API has the safety contract below. + +## Explicit deletion safety + +Deletion checks the case-insensitive ownership marker, then uses that read's ETag and includes +snapshots. Unowned blobs are never deleted. Only confirmed `BlobNotFound`/`ContainerNotFound` +responses count as already absent; uncertain failures propagate. Configuring the provider alone +does not invoke deletion. Later cleanup phases add the authenticated backend lifecycle and runner. +`Deleted` means storage accepted deletion or confirmed a terminal outcome, **not** immediate +physical reclamation. Soft-delete retention and blob versions can continue to consume storage. + +## Local verification + +Unit tests use the real Azure SDK pipeline with an offline HTTP transport and checked-in +gzip vectors generated by .NET 10.0.13 using the pinned implementation's write/flush sequence. +Run `npm run test:unit -w @microsoft/durabletask-js-azureblob`. +The optional `test:azurite` suite requires a local-only +`DURABLETASK_BLOB_TEST_CONNECTION_STRING`. Its explicit local endpoint is checked against the +SDK-parsed service URL before any I/O; the suite never provisions cloud resources. diff --git a/packages/durabletask-js-azureblob/jest.config.js b/packages/durabletask-js-azureblob/jest.config.js new file mode 100644 index 00000000..2696e51a --- /dev/null +++ b/packages/durabletask-js-azureblob/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/test"], + testMatch: ["**/*.spec.ts"], + moduleNameMapper: { + "^@microsoft/durabletask-js$": "/../durabletask-js/src/index.ts", + }, +}; diff --git a/packages/durabletask-js-azureblob/package.json b/packages/durabletask-js-azureblob/package.json new file mode 100644 index 00000000..1faf4395 --- /dev/null +++ b/packages/durabletask-js-azureblob/package.json @@ -0,0 +1,44 @@ +{ + "name": "@microsoft/durabletask-js-azureblob", + "version": "0.4.0", + "description": "Azure Blob externalized payload storage for the Durable Task JavaScript SDK", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "CHANGELOG.md", + "LICENSE", + "README.md" + ], + "scripts": { + "clean": "node -e \"require('fs').rmSync('dist', {recursive:true, force:true})\"", + "build": "npm run clean && tsc -p tsconfig.build.json", + "test": "jest --runInBand --detectOpenHandles", + "test:unit": "jest test --runInBand --detectOpenHandles", + "test:azurite": "jest test/azurite.spec.ts --runInBand --detectOpenHandles", + "prepublishOnly": "npm run build && npm run test" + }, + "engines": { + "node": ">=22.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/durabletask-js.git", + "directory": "packages/durabletask-js-azureblob" + }, + "license": "MIT", + "dependencies": { + "@azure/core-auth": "^1.10.0", + "@azure/storage-blob": "^12.26.0" + }, + "peerDependencies": { + "@microsoft/durabletask-js": ">=0.4.1" + } +} diff --git a/packages/durabletask-js-azureblob/src/blob-payload-store.ts b/packages/durabletask-js-azureblob/src/blob-payload-store.ts new file mode 100644 index 00000000..cbcfef2b --- /dev/null +++ b/packages/durabletask-js-azureblob/src/blob-payload-store.ts @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TokenCredential } from "@azure/core-auth"; +import { + BlobClient, + BlobServiceClient, + ContainerClient, + newPipeline, + RestError, + StoragePipelineOptions, +} from "@azure/storage-blob"; +import { PayloadStore, PayloadStorageException, PayloadDeleteOutcome } from "@microsoft/durabletask-js"; +import { randomUUID } from "crypto"; +import { Writable } from "stream"; +import { pipeline } from "stream/promises"; +import { promisify } from "util"; +import { createGunzip, gzip } from "zlib"; + +const compress = promisify(gzip); + +export interface BlobPayloadStoreOptions { + connectionString?: string; + accountUri?: string | URL; + credential?: TokenCredential; + /** Defaults to durabletask-payloads. */ + containerName?: string; + /** Defaults to true. */ + compressionEnabled?: boolean; + /** Azure SDK HTTP, proxy, and retry configuration. */ + clientOptions?: StoragePipelineOptions; +} + +/** .NET-compatible blob:v2 writes and blob:v1/v2 reads, with ownership-checked conditional cleanup. */ +export class BlobPayloadStore extends PayloadStore { + private readonly _service: BlobServiceClient; + private readonly _container: ContainerClient; + private readonly _clientOptions: StoragePipelineOptions; + private _generation?: object; + private _initializing?: { promise: Promise; signal?: AbortSignal }; + + constructor(private readonly _options: BlobPayloadStoreOptions) { + super(); + const containerName = _options.containerName ?? "durabletask-payloads"; + if (!containerName) throw new TypeError("containerName must not be empty."); + const retryOptions = _options.clientOptions?.retryOptions; + this._clientOptions = { + ..._options.clientOptions, + retryOptions: { + ...retryOptions, + maxTries: retryOptions?.maxTries ?? 9, + retryDelayInMs: retryOptions?.retryDelayInMs ?? 250, + maxRetryDelayInMs: retryOptions?.maxRetryDelayInMs ?? 10000, + tryTimeoutInMs: retryOptions?.tryTimeoutInMs ?? 120000, + }, + }; + if (_options.accountUri && _options.credential) { + this._service = new BlobServiceClient( + String(_options.accountUri), + newPipeline(_options.credential, this._clientOptions), + ); + } else if (_options.connectionString) { + const parsed = BlobServiceClient.fromConnectionString(_options.connectionString); + this._service = new BlobServiceClient(parsed.url, newPipeline(parsed.credential, this._clientOptions)); + } else { + throw new TypeError("Provide connectionString or both accountUri and credential."); + } + this._container = this._service.getContainerClient(containerName); + } + + isKnownPayloadToken(value: string): boolean { + return value.startsWith("blob:v1:") || value.startsWith("blob:v2:"); + } + + async upload(payload: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const blob = this._container.getBlockBlobClient(randomUUID().replace(/-/g, "")); + const compressed = this._options.compressionEnabled ?? true; + const bytes = Buffer.from(payload, "utf8"); + const content = compressed ? await compress(bytes) : bytes; + signal?.throwIfAborted(); + for (let attempt = 0; ; attempt++) { + const generation = await this._ensureContainer(signal); + try { + await blob.upload(content, content.length, { + abortSignal: signal, + metadata: { managed_by: "dts" }, + blobHTTPHeaders: compressed ? { blobContentEncoding: "gzip" } : undefined, + }); + } catch (error) { + signal?.throwIfAborted(); + if (attempt === 0 && storageCode(error) === "ContainerNotFound") { + if (this._generation === generation) this._generation = undefined; + continue; + } + throw error; + } + const url = new URL(blob.url); + url.search = ""; + url.hash = ""; + url.username = ""; + url.password = ""; + return `blob:v2:${url.href}`; + } + } + + async download(token: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const blob = this._blobClient(token); + try { + const response = await blob.download(0, undefined, { abortSignal: signal }); + const body = response.readableStreamBody; + if (!body) throw new PayloadStorageException("The payload blob response has no readable body."); + const chunks: Buffer[] = []; + const target = new Writable({ + write(chunk: Buffer, _encoding, callback) { + chunks.push(chunk); + callback(); + }, + }); + if (response.contentEncoding?.toLowerCase() === "gzip") { + await pipeline(body, createGunzip(), target, { signal }); + } else { + await pipeline(body, target, { signal }); + } + return Buffer.concat(chunks) + .toString("utf8") + .replace(/^\uFEFF/, ""); + } catch (error) { + signal?.throwIfAborted(); + throwIfSecondaryNotFound(error, blob.url); + if (storageStatus(error) === 404) { + throw new PayloadStorageException("The payload blob was not found. It may have been deleted.", { + cause: error, + }); + } + throw error; + } + } + + async delete(token: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const blob = this._blobClient(token); + try { + const properties = await blob.getProperties({ abortSignal: signal }); + const owned = Object.entries(properties.metadata ?? {}).some( + ([key, value]) => key.toLowerCase() === "managed_by" && value === "dts", + ); + if (!owned) return PayloadDeleteOutcome.NotStoreOwned; + if (!properties.etag) throw new PayloadStorageException("The ownership response is missing its ETag."); + await blob.delete({ + abortSignal: signal, + deleteSnapshots: "include", + conditions: { ifMatch: properties.etag }, + }); + return PayloadDeleteOutcome.Deleted; + } catch (error) { + signal?.throwIfAborted(); + throwIfSecondaryNotFound(error, blob.url); + if ( + storageStatus(error) === 404 && + (storageCode(error) === "BlobNotFound" || storageCode(error) === "ContainerNotFound") + ) { + return PayloadDeleteOutcome.AlreadyAbsent; + } + throw error; + } + } + + private _blobClient(token: string): BlobClient { + if (token.startsWith("blob:v1:")) { + const rest = token.slice("blob:v1:".length); + const separator = rest.indexOf(":"); + if ( + separator < 1 || + separator === rest.length - 1 || + rest.slice(0, separator) !== this._container.containerName + ) { + throw new TypeError("Invalid external payload token or token container does not match."); + } + return this._container.getBlobClient(rest.slice(separator + 1)); + } + if (!token.startsWith("blob:v2:")) throw new TypeError("Invalid external payload token."); + let url: URL; + try { + url = new URL(token.slice("blob:v2:".length)); + } catch (error) { + throw new TypeError("Invalid external payload blob URL.", { cause: error }); + } + if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) { + throw new TypeError("Invalid external payload blob URL."); + } + const decoded = new BlobClient(url.href); + if (!decoded.containerName || !decoded.name) throw new TypeError("Invalid external payload blob URL."); + const container = this._service.getContainerClient(decoded.containerName); + const configuredUrl = new URL(container.url); + // Match the service/container boundary, not normalized blob-name escaping. The SDK + // preserves path-style account prefixes and the configured credential/pipeline. + if ( + url.origin === configuredUrl.origin && + url.pathname.startsWith(configuredUrl.pathname.replace(/\/+$/, "") + "/") + ) { + return container.getBlobClient(decoded.name); + } + if (this._options.credential) + return new BlobClient( + url.href, + newPipeline(this._options.credential, { + ...this._clientOptions, + retryOptions: { ...this._clientOptions.retryOptions, secondaryHost: undefined }, + }), + ); + throw new PayloadStorageException( + "Cross-account or cross-endpoint payload access requires identity authentication.", + ); + } + + private async _ensureContainer(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + if (this._generation) return this._generation; + if (!this._initializing) { + const initializing = { + signal, + promise: this._container.createIfNotExists({ abortSignal: signal }).then(() => { + this._generation = {}; + return this._generation; + }), + }; + this._initializing = initializing; + const clear = () => { + if (this._initializing === initializing) this._initializing = undefined; + }; + void initializing.promise.then(clear, clear); + } + const initializing = this._initializing; + try { + return await waitForInitialization(initializing.promise, signal); + } catch (error) { + if (initializing.signal?.aborted && !signal?.aborted) { + if (this._initializing === initializing) this._initializing = undefined; + return this._ensureContainer(signal); + } + throw error; + } + } +} + +async function waitForInitialization(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + let onAbort = () => {}; + try { + return await new Promise((resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then(resolve, reject); + if (signal.aborted) onAbort(); + }); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function storageCode(error: unknown): unknown { + if (error === null || typeof error !== "object") return undefined; + // HEAD failures have no response body; the Azure SDK exposes their service code in details. + if ( + "details" in error && + error.details !== null && + typeof error.details === "object" && + "errorCode" in error.details && + typeof error.details.errorCode === "string" + ) { + return error.details.errorCode; + } + return "code" in error ? error.code : undefined; +} + +function storageStatus(error: unknown): unknown { + return error !== null && typeof error === "object" && "statusCode" in error ? error.statusCode : undefined; +} + +function throwIfSecondaryNotFound(error: unknown, primaryUrl: string): void { + if ( + error instanceof RestError && + error.statusCode === 404 && + error.request && + new URL(error.request.url).host !== new URL(primaryUrl).host + ) { + throw Object.assign( + new RestError("A secondary response did not confirm whether the primary payload exists.", { + code: RestError.REQUEST_SEND_ERROR, + }), + { cause: error }, + ); + } +} diff --git a/packages/durabletask-js-azureblob/src/index.ts b/packages/durabletask-js-azureblob/src/index.ts new file mode 100644 index 00000000..1518fd48 --- /dev/null +++ b/packages/durabletask-js-azureblob/src/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export { BlobPayloadStore, BlobPayloadStoreOptions } from "./blob-payload-store"; diff --git a/packages/durabletask-js-azureblob/test/azurite.spec.ts b/packages/durabletask-js-azureblob/test/azurite.spec.ts new file mode 100644 index 00000000..c31b3186 --- /dev/null +++ b/packages/durabletask-js-azureblob/test/azurite.spec.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { randomUUID, createHash } from "crypto"; +import { ContainerClient } from "@azure/storage-blob"; +import { PayloadDeleteOutcome } from "@microsoft/durabletask-js"; +import { BlobPayloadStore } from "../src"; +import fixtures from "./dotnet-payload-fixtures.json"; +import { createLocalAzuriteClient } from "./local-azurite-client"; + +const connectionString = process.env.DURABLETASK_BLOB_TEST_CONNECTION_STRING; +const service = createLocalAzuriteClient(connectionString); + +(service ? describe : describe.skip)("BlobPayloadStore with local Azurite", () => { + let container: ContainerClient; + let containerName: string; + beforeEach(() => { + containerName = `payloads-${randomUUID()}`; + container = service!.getContainerClient(containerName); + }); + afterEach(async () => { + await container.deleteIfExists(); + }); + + it.each([true, false])("round-trips UTF-8 payloads and same-account tokens (gzip=%s)", async (compressionEnabled) => { + const store = new BlobPayloadStore({ connectionString, containerName, compressionEnabled }); + const value = "\u00e9\ud83d\ude80".repeat(100000); + const token = await store.upload(value); + expect(await store.download(token)).toBe(value); + const url = new URL(token.slice("blob:v2:".length)); + const blobName = url.pathname.split("/").at(-1)!; + expect(await store.download(`blob:v1:${containerName}:${blobName}`)).toBe(value); + const properties = await container.getBlobClient(blobName).getProperties(); + expect(properties.metadata).toMatchObject({ managed_by: "dts" }); + expect(properties.contentEncoding ?? "").toBe(compressionEnabled ? "gzip" : ""); + const rotated = new BlobPayloadStore({ connectionString, containerName: `rotated-${randomUUID()}` }); + expect(await rotated.download(token)).toBe(value); + }); + + it.each(fixtures.vectors)("hydrates .NET-produced compressed blob $id", async (fixture) => { + await container.create(); + const blob = container.getBlockBlobClient("dotnet"); + const bytes = Buffer.from(fixture.gzipBase64, "base64"); + await blob.upload(bytes, bytes.length, { + metadata: { managed_by: "dts" }, + blobHTTPHeaders: { blobContentEncoding: "gzip" }, + }); + const store = new BlobPayloadStore({ connectionString, containerName }); + const actual = await store.download(`blob:v2:${blob.url}`); + expect(Buffer.byteLength(actual, "utf8")).toBe(fixture.utf8Bytes); + expect(createHash("sha256").update(actual, "utf8").digest("hex")).toBe(fixture.sha256); + }); + + it("deletes owned blobs and snapshots but never deletes unowned blobs", async () => { + const store = new BlobPayloadStore({ connectionString, containerName }); + const token = await store.upload("owned"); + const owned = container.getBlobClient(new URL(token.slice("blob:v2:".length)).pathname.split("/").at(-1)!); + await owned.createSnapshot(); + expect(await store.delete(token)).toBe(PayloadDeleteOutcome.Deleted); + expect(await store.delete(token)).toBe(PayloadDeleteOutcome.AlreadyAbsent); + expect(await owned.exists()).toBe(false); + const customer = container.getBlockBlobClient("customer"); + await customer.upload("customer data", 13); + expect(await store.delete(`blob:v2:${customer.url}`)).toBe(PayloadDeleteOutcome.NotStoreOwned); + expect(await customer.exists()).toBe(true); + }); +}); diff --git a/packages/durabletask-js-azureblob/test/blob-network.spec.ts b/packages/durabletask-js-azureblob/test/blob-network.spec.ts new file mode 100644 index 00000000..eac09c29 --- /dev/null +++ b/packages/durabletask-js-azureblob/test/blob-network.spec.ts @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { createServer, IncomingMessage, ServerResponse, Server } from "http"; +import { Socket } from "net"; +import { getEventListeners } from "events"; +import { Readable } from "stream"; +import { BlobServiceClient } from "@azure/storage-blob"; +import { BlobPayloadStore } from "../src"; +import { fork } from "child_process"; +import { join } from "path"; + +describe("BlobPayloadStore native SDK networking", () => { + let server: Server; + let endpoint: string; + let handler: (request: IncomingMessage, response: ServerResponse) => void; + let requests: IncomingMessage[]; + const sockets = new Set(); + const timers = new Set(); + const timeout = 100; + const etag = '"original-payload"'; + const accountKey = Buffer.alloc(64).toString("base64"); + const make = (maxTries = 1) => + new BlobPayloadStore({ + connectionString: `DefaultEndpointsProtocol=http;AccountName=account;AccountKey=${accountKey};BlobEndpoint=${endpoint}`, + compressionEnabled: false, + clientOptions: { + retryOptions: { tryTimeoutInMs: timeout, maxTries, retryDelayInMs: 0, maxRetryDelayInMs: 0 }, + }, + }); + const token = () => `blob:v2:${endpoint}/durabletask-payloads/blob`; + const headers = (response: ServerResponse, length: number, status = 200, encoding?: string) => { + response.writeHead(status, { + "content-length": String(length), + etag, + "x-ms-request-id": "local-deadline-test", + "x-ms-blob-type": "BlockBlob", + ...(encoding ? { "content-encoding": encoding } : {}), + }); + response.flushHeaders(); + }; + + beforeAll(async () => { + server = createServer((request, response) => { + response.setHeader("connection", "close"); + requests.push(request); + handler(request, response); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected loopback TCP server"); + endpoint = `http://127.0.0.1:${address.port}/account`; + }); + + beforeEach(() => { + requests = []; + }); + + afterEach(async () => { + for (const timer of timers) clearTimeout(timer); + timers.clear(); + const closed = [...sockets].map((socket) => new Promise((resolve) => socket.once("close", resolve))); + server.closeAllConnections(); + await Promise.all(closed); + }); + + afterAll(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }); + + async function waitForRequest(started: Promise, operation: Promise): Promise { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + started, + operation.then( + () => { + throw new Error("The operation completed before the server received the request."); + }, + (error: unknown) => { + throw error; + }, + ), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("The server did not receive the request.")), 1000); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + it.each(["abort", "reset"] as const)( + "lets the native SDK settle an active body on %s without crashing or hanging", + async (action) => { + let active!: ServerResponse; + handler = (_request, response) => { + active = response; + headers(response, 100); + }; + const child = fork(join(__dirname, "fixtures", "active-download.js"), [endpoint], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + child.stdout!.resume(); + let stderr = ""; + child.stderr!.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + let watchdog: NodeJS.Timeout | undefined; + let observationTimer: NodeJS.Timeout | undefined; + let watchdogFired = false; + let bytes = 0; + let complete = true; + let observation: + | { + result?: { outcome: string; originalReason?: boolean; name: string }; + callerAborted: boolean; + callerListeners: number; + rawClosed: boolean; + } + | undefined; + child.on( + "message", + (message: NonNullable & { type: string; bytes: number; complete: boolean }) => { + if (message.type === "ready") { + watchdog = setTimeout(() => { + watchdogFired = true; + child.kill(); + }, 1000); + } else if (message.type === "headers") active.write("prefix"); + else if (message.type === "body") { + bytes = message.bytes; + complete = message.complete; + if (action === "abort") child.send("abort"); + else active.socket!.destroy(); + observationTimer = setTimeout(() => { + if (child.connected) child.send("observe"); + }, 150); + } else if (message.type === "observation") { + observation = message; + child.send("cleanup"); + } + }, + ); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + expect({ code, stderr, watchdogFired }).toEqual({ code: 0, stderr: "", watchdogFired: false }); + expect(bytes).toBe(6); + expect(complete).toBe(false); + expect(observation?.result).toMatchObject({ + outcome: "rejected", + originalReason: action === "abort", + }); + expect(observation?.callerAborted).toBe(action === "abort"); + expect(observation?.callerListeners).toBe(0); + expect(observation?.rawClosed).toBe(true); + expect(requests).toHaveLength(1); + } finally { + clearTimeout(watchdog); + clearTimeout(observationTimer); + if (child.exitCode === null && child.signalCode === null) child.kill(); + } + }, + ); + + it("reads a progressive response through the native SDK", async () => { + handler = (_request, response) => { + headers(response, 8); + let written = 0; + const write = () => { + response.write("x"); + if (++written === 8) response.end(); + else timers.add(setTimeout(write, 25)); + }; + timers.add(setTimeout(write, 25)); + }; + await expect(make().download(token())).resolves.toBe("xxxxxxxx"); + expect(requests).toHaveLength(1); + }); + + it("does not HTTP-retry corrupt gzip after a complete response", async () => { + handler = (_request, response) => { + headers(response, 7, 200, "gzip"); + response.end("notgzip"); + }; + await expect(make(3).download(token())).rejects.toMatchObject({ code: "Z_DATA_ERROR" }); + expect(requests).toHaveLength(1); + }); + + it.each([0, undefined])( + "eagerly closes a corrupt gzip response with an open tail (timeout=%s)", + async (tryTimeoutInMs) => { + let responseClosed = false; + let socketClosed = false; + let socketClose!: () => void; + const closed = new Promise((resolve) => (socketClose = resolve)); + handler = (_request, response) => { + response.once("close", () => { + responseClosed = true; + }); + response.socket!.once("close", () => { + socketClosed = true; + socketClose(); + }); + headers(response, 100, 200, "gzip"); + response.write("notgzip"); + }; + const controller = new AbortController(); + const watchdog = setTimeout( + () => controller.abort(new Error("test watchdog: gzip failure did not settle")), + 1000, + ); + const store = new BlobPayloadStore({ + connectionString: `DefaultEndpointsProtocol=http;AccountName=account;AccountKey=${accountKey};BlobEndpoint=${endpoint}`, + clientOptions: { retryOptions: { tryTimeoutInMs, maxTries: 1 } }, + }); + const operation = store.download(token(), controller.signal); + try { + await expect(operation).rejects.toMatchObject({ code: "Z_DATA_ERROR" }); + await closed; + expect({ + responseClosed, + socketClosed, + openSockets: sockets.size, + callerAborted: controller.signal.aborted, + callerListeners: getEventListeners(controller.signal, "abort").length, + gets: requests.filter((request) => request.method === "GET").length, + }).toEqual({ + responseClosed: true, + socketClosed: true, + openSockets: 0, + callerAborted: false, + callerListeners: 0, + gets: 1, + }); + } finally { + clearTimeout(watchdog); + controller.abort(new Error("test-only final cleanup after observation")); + server.closeAllConnections(); + if (requests.length) await closed; + await operation.catch(() => {}); + } + }, + ); + + it("keeps a normally completed response connection available for the next download", async () => { + const connections: Socket[] = []; + handler = (request, response) => { + connections.push(request.socket); + response.setHeader("connection", "keep-alive"); + headers(response, 7); + response.end("payload"); + }; + const store = make(); + await expect(store.download(token())).resolves.toBe("payload"); + expect(connections[0].destroyed).toBe(false); + await expect(store.download(token())).resolves.toBe("payload"); + expect(connections).toHaveLength(2); + expect(connections[1]).toBe(connections[0]); + expect(connections[1].destroyed).toBe(false); + }); + + it("matches the native SDK's disabled keep-alive behavior", async () => { + const connections: Socket[] = []; + handler = (request, response) => { + connections.push(request.socket); + response.setHeader("connection", "keep-alive"); + headers(response, 7); + response.end("payload"); + }; + const localConnection = `DefaultEndpointsProtocol=http;AccountName=account;AccountKey=${accountKey};BlobEndpoint=${endpoint}`; + const native = BlobServiceClient.fromConnectionString(localConnection, { + keepAliveOptions: { enable: false }, + retryOptions: { maxTries: 1 }, + }) + .getContainerClient("durabletask-payloads") + .getBlobClient("blob"); + for (let i = 0; i < 2; i++) { + const { readableStreamBody: body } = await native.download(); + if (!(body instanceof Readable)) throw new Error("Expected the SDK's Node-readable download body"); + const chunks: Buffer[] = []; + for await (const chunk of body) chunks.push(chunk); + expect(Buffer.concat(chunks).toString()).toBe("payload"); + } + const store = new BlobPayloadStore({ + connectionString: localConnection, + clientOptions: { + keepAliveOptions: { enable: false }, + retryOptions: { maxTries: 1, tryTimeoutInMs: timeout }, + }, + }); + await expect(store.download(token())).resolves.toBe("payload"); + await expect(store.download(token())).resolves.toBe("payload"); + expect(connections).toHaveLength(4); + expect(connections[2] === connections[3]).toBe(connections[0] === connections[1]); + }); + + it.each(["headers", "body", "initialization"] as const)( + "preserves caller abort identity during %s and closes I/O", + async (stage) => { + let received!: () => void; + let closed!: () => void; + const started = new Promise((resolve) => (received = resolve)); + const finished = new Promise((resolve) => (closed = resolve)); + handler = (_request, response) => { + response.once("close", closed); + if (stage === "body") { + headers(response, 7); + response.write("p"); + } + received(); + }; + const controller = new AbortController(); + const reason = Object.freeze(new Error("caller canceled provider")); + const pending = + stage === "initialization" + ? make(3).upload("owner", controller.signal) + : make(3).download(token(), controller.signal); + const rejected = pending.catch((error: unknown) => error); + await waitForRequest(started, pending); + controller.abort(reason); + expect(await rejected).toBe(reason); + await finished; + expect(requests).toHaveLength(1); + }, + ); + + it("cancels only an initialization waiter and drains/reuses the surviving single flight", async () => { + let initialization!: ServerResponse; + let received!: () => void; + const started = new Promise((resolve) => (received = resolve)); + handler = (request, response) => { + if (request.url?.includes("restype=container")) { + initialization = response; + received(); + } else { + request.resume(); + response.writeHead(201, { "content-length": "0", etag }); + response.end(); + } + }; + const store = make(); + const leader = store.upload("leader"); + const leaderSettled = leader.catch((error: unknown) => error); + await waitForRequest(started, leader); + const controller = new AbortController(); + const reason = Object.freeze(new Error("cancel only the initialization waiter")); + const waiter = store.upload("waiter", controller.signal).catch((error: unknown) => error); + controller.abort(reason); + expect(await waiter).toBe(reason); + expect(initialization.destroyed).toBe(false); + initialization.writeHead(201, { "content-length": "0" }); + initialization.end(); + await expect(leaderSettled).resolves.toMatch(/^blob:v2:/); + await expect(store.upload("later")).resolves.toMatch(/^blob:v2:/); + expect(requests.filter((request) => request.url?.includes("restype=container"))).toHaveLength(1); + expect(requests).toHaveLength(3); + }); + + it("leaves a stalled SDK request caller-cancellable with a zero server timeout hint", async () => { + let received!: () => void; + const started = new Promise((resolve) => (received = resolve)); + handler = () => received(); + const controller = new AbortController(); + const reason = new Error("cancel explicit infinite timeout"); + const store = new BlobPayloadStore({ + connectionString: `DefaultEndpointsProtocol=http;AccountName=account;AccountKey=${accountKey};BlobEndpoint=${endpoint}`, + clientOptions: { retryOptions: { tryTimeoutInMs: 0, maxTries: 1 } }, + }); + let settled = false; + const operation = store.download(token(), controller.signal); + const pending = operation.then( + (value) => { + settled = true; + return value; + }, + (error: unknown) => { + settled = true; + return error; + }, + ); + try { + await waitForRequest(started, operation); + await new Promise((resolve) => timers.add(setTimeout(resolve, timeout * 2))); + expect(settled).toBe(false); + controller.abort(reason); + expect(await pending).toBe(reason); + expect(requests).toHaveLength(1); + } finally { + controller.abort(reason); + await pending; + } + }); + + it("surfaces early operation failures instead of hanging at the server-receipt gate", async () => { + const neverReceived = new Promise(() => {}); + await expect(waitForRequest(neverReceived, make().download("blob:v2:invalid"))).rejects.toBeInstanceOf(TypeError); + expect(requests).toHaveLength(0); + }); +}); diff --git a/packages/durabletask-js-azureblob/test/blob-payload-store.spec.ts b/packages/durabletask-js-azureblob/test/blob-payload-store.spec.ts new file mode 100644 index 00000000..386fbbc4 --- /dev/null +++ b/packages/durabletask-js-azureblob/test/blob-payload-store.spec.ts @@ -0,0 +1,1043 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Readable } from "stream"; +import { getEventListeners } from "events"; +import { gunzipSync, gzipSync } from "zlib"; +import { + BlobClient, + IHttpClient, + HttpOperationResponse, + WebResource, + RestError, + StorageRetryOptions, + StorageRetryPolicyType, +} from "@azure/storage-blob"; +import { PayloadDeleteOutcome, PayloadStorageException } from "@microsoft/durabletask-js"; +import { BlobPayloadStore } from "../src"; +import fixtures from "./dotnet-payload-fixtures.json"; + +const accountKey = Buffer.alloc(64).toString("base64"); +const connectionString = `DefaultEndpointsProtocol=https;AccountName=account;AccountKey=${accountKey};BlobEndpoint=https://account.invalid`; + +class RecordingClient implements IHttpClient { + requests: WebResource[] = []; + times: number[] = []; + failures: (Error | { status: number; code: string; headers?: Record } | undefined)[] = []; + bodies: Buffer[] = []; + content = Buffer.from("payload"); + encoding = ""; + metadata: Record = { managed_by: "dts" }; + error: { status: number; code: string } | undefined; + failFirstUpload = false; + async sendRequest(request: WebResource): Promise { + this.requests.push(request); + this.times.push(Date.now()); + const headers = request.headers.clone(); + for (const header of headers.headersArray()) headers.remove(header.name); + headers.set("x-ms-request-id", "offline"); + headers.set("etag", '"owned-etag"'); + headers.set("last-modified", new Date(0).toUTCString()); + headers.set("x-ms-blob-type", "BlockBlob"); + for (const [key, value] of Object.entries(this.metadata)) headers.set(`x-ms-meta-${key}`, value); + if (this.encoding) headers.set("content-encoding", this.encoding); + const container = new URL(request.url).searchParams.get("restype") === "container"; + const injected = this.failures.shift(); + if (injected instanceof Error) throw injected; + const error: { status: number; code: string; headers?: Record } | undefined = + injected ?? + (this.failFirstUpload && request.method === "PUT" && !container + ? { status: 404, code: "ContainerNotFound" } + : this.error); + if (error) { + this.failFirstUpload = false; + headers.set("x-ms-error-code", error.code); + headers.set("content-type", "application/xml"); + for (const [name, value] of Object.entries(error.headers ?? {})) headers.set(name, value); + return { + request, + status: error.status, + headers, + bodyAsText: + request.method === "HEAD" ? "" : `${error.code}offline`, + }; + } + if (request.method === "PUT" && !container) { + const body = typeof request.body === "function" ? request.body() : request.body; + if (!Buffer.isBuffer(body)) throw new Error("Expected a buffered upload"); + this.bodies.push(body); + } + headers.set("content-length", String(this.content.length)); + return { + request, + status: request.method === "PUT" ? 201 : request.method === "DELETE" ? 202 : 200, + headers, + readableStreamBody: Readable.from([this.content]), + bodyAsText: "", + }; + } +} + +describe("BlobPayloadStore", () => { + let http: RecordingClient; + beforeEach(() => { + http = new RecordingClient(); + }); + + describe("BlobPayloadStore native SDK retries and options", () => { + let http: RecordingClient; + const token = "blob:v2:https://account.invalid/durabletask-payloads/blob"; + const make = (http: RecordingClient, retryOptions?: StorageRetryOptions) => + new BlobPayloadStore({ + connectionString, + compressionEnabled: false, + clientOptions: { httpClient: http, retryOptions }, + }); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2030-01-01T00:00:00Z")); + jest.spyOn(Math, "random").mockReturnValue(0.5); + http = new RecordingClient(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it.each([500, 503])("uses the native immediate first retry for ordinary HTTP %s", async (status) => { + http.failures.push({ status, code: "Transient" }); + const pending = make(http).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + expect(http.times[1] - http.times[0]).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each(["InternalError", "OperationTimedOut", "ServerBusy"])( + "uses native copy-source classification, not ordinary service code %s", + async (code) => { + for (const name of ["x-ms-error-code", "x-ms-copy-source-error-code"]) { + http.failures.push({ + status: 400, + code: name === "x-ms-error-code" ? code : "Other", + headers: { [name]: code }, + }); + const pending = make(http).download(token); + const assertion = + name === "x-ms-copy-source-error-code" + ? expect(pending).resolves.toBe("payload") + : expect(pending).rejects.toMatchObject({ statusCode: 400 }); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + } + expect(http.requests).toHaveLength(3); + }, + ); + + it("uses exactly nine native attempts with the same timing as the raw SDK, without nested retries", async () => { + http.error = { status: 503, code: "ServerBusy" }; + const pending = make(http).download(token); + const assertion = expect(pending).rejects.toMatchObject({ statusCode: 503 }); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(9); + const raw = new RecordingClient(); + raw.error = http.error; + const native = new BlobClient(token.slice("blob:v2:".length), undefined, { + httpClient: raw, + retryOptions: { maxTries: 9, retryDelayInMs: 250, maxRetryDelayInMs: 10000 }, + }).download(); + const nativeAssertion = expect(native).rejects.toMatchObject({ statusCode: 503 }); + await jest.runAllTimersAsync(); + await nativeAssertion; + const delays = (times: number[]) => times.slice(1).map((time, index) => time - times[index]); + expect(raw.requests).toHaveLength(9); + expect(delays(raw.times)).toEqual([0, 250, 750, 1750, 3750, 7750, 10000, 10000]); + expect(delays(http.times)).toEqual(delays(raw.times)); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([400, 401, 403, 404, 408, 409, 412, 429, 502, 504])( + "uses the SDK's terminal classification for ordinary HTTP %s", + async (status) => { + http.error = { status, code: "Permanent" }; + const pending = make(http).download(token); + const assertion = expect(pending).rejects.toThrow(); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + }, + ); + + it.each([1, 2])("honors caller maxTries=%s as a non-nested HTTP attempt budget", async (maxTries) => { + http.error = { status: 503, code: "ServerBusy" }; + const assertion = expect(make(http, { maxTries, retryDelayInMs: 0 }).download(token)).rejects.toMatchObject({ + statusCode: 503, + }); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(maxTries); + expect(jest.getTimerCount()).toBe(0); + }); + + it("retries recognized network failures without repeating the upload invocation or changing its GUID/content", async () => { + http.failures.push(undefined, new RestError("socket disconnected", { code: "ECONNRESET" })); + const pending = make(http).upload("same payload"); + const assertion = expect(pending).resolves.toMatch(/^blob:v2:/); + await jest.runAllTimersAsync(); + await assertion; + const uploads = http.requests.filter((request) => !request.url.includes("restype=container")); + expect(uploads).toHaveLength(2); + expect(uploads[0].url).toBe(uploads[1].url); + expect(http.bodies.map((body) => body.toString())).toEqual(["same payload"]); + }); + + it.each(["initialize", "upload", "download", "ownership", "delete"] as const)( + "applies one HTTP retry policy to %s", + async (operation) => { + if (operation === "upload" || operation === "delete") http.failures.push(undefined); + http.failures.push({ status: 503, code: "ServerBusy" }); + const store = make(http); + const pending = + operation === "initialize" || operation === "upload" + ? store.upload("payload") + : operation === "download" + ? store.download(token) + : store.delete(token); + const assertion = expect(pending).resolves.toBeDefined(); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(operation === "download" ? 2 : 3); + }, + ); + + it.each>([ + { "retry-after-ms": "1300", "x-ms-retry-after-ms": "2200", "Retry-After": "3" }, + { "Retry-After": "Tue, 01 Jan 2030 00:00:03 GMT" }, + { "Retry-After": "2147484" }, + ])("leaves Retry-After behavior to the native SDK (%#)", async (headers) => { + http.failures.push({ status: 503, code: "ServerBusy", headers }); + const pending = make(http).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(http.times[1] - http.times[0]).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([0, 0.999])("uses native primary exponential delay without custom jitter (random=%s)", async (random) => { + jest.mocked(Math.random).mockReturnValue(random); + http.failures.push({ status: 503, code: "ServerBusy" }, { status: 503, code: "ServerBusy" }); + const pending = make(http).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(http.times.slice(1).map((time, index) => time - http.times[index])).toEqual([0, 250]); + }); + + it.each([ + { maximum: 5, random: 0.5 }, + { maximum: 100, random: 0.999 }, + { maximum: 100, random: 0 }, + ])("preserves native FIXED delay without custom caps or jitter ($maximum/$random)", async ({ maximum, random }) => { + jest.mocked(Math.random).mockReturnValue(random); + http.failures.push({ status: 503, code: "ServerBusy" }); + const pending = make(http, { + maxTries: 2, + retryDelayInMs: 30, + maxRetryDelayInMs: maximum, + retryPolicyType: StorageRetryPolicyType.FIXED, + }).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + expect(http.times[1] - http.times[0]).toBe(30); + }); + + it("preserves explicit retry count, delay, cap, and fixed-policy overrides", async () => { + http.error = { status: 503, code: "ServerBusy" }; + const pending = make(http, { + maxTries: 3, + retryDelayInMs: 30, + maxRetryDelayInMs: 30, + retryPolicyType: StorageRetryPolicyType.FIXED, + }).download(token); + const assertion = expect(pending).rejects.toMatchObject({ statusCode: 503 }); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(3); + expect(http.times.slice(1).map((time, index) => time - http.times[index])).toEqual([30, 30]); + }); + + it("preserves caller abort identity while canceling retry backoff", async () => { + http.error = { status: 503, code: "ServerBusy" }; + const controller = new AbortController(); + const reason = Object.freeze(new Error("caller canceled storage")); + const pending = make(http, { + retryPolicyType: StorageRetryPolicyType.FIXED, + retryDelayInMs: 1000, + }).download(token, controller.signal); + const rejected = pending.catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(0); + controller.abort(reason); + await jest.runAllTimersAsync(); + expect(await rejected).toBe(reason); + expect(http.requests).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each([ + new RestError("socket reset", { code: "ECONNRESET" }), + new RestError("transport send failed", { code: RestError.REQUEST_SEND_ERROR }), + ])("recovers from a transient I/O failure %#", async (failure) => { + http.failures.push(failure); + const pending = make(http).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + }); + + it.each([ + new RestError("status zero without a native retry code", { statusCode: 0 }), + Object.assign(new Error("custom transport failure"), { code: "ECONNRESET" }), + ])("leaves non-native I/O classifications to the SDK (%#)", async (failure) => { + http.failures.push(failure); + const assertion = expect(make(http).download(token)).rejects.toBe(failure); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + }); + + it("preserves configured read failover and stops using a secondary after its 404", async () => { + const hosts: string[] = []; + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + hosts.push(new URL(request.url).host); + return send(request); + }; + http.failures.push({ status: 503, code: "ServerBusy" }, { status: 404, code: "BlobNotFound" }); + const pending = make(http, { secondaryHost: "secondary.invalid" }).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(hosts).toEqual(["account.invalid", "secondary.invalid", "account.invalid"]); + }); + + it("does not redirect a cross-account identity retry to the configured account's secondary", async () => { + const hosts: string[] = []; + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + hosts.push(new URL(request.url).host); + return send(request); + }; + http.failures.push({ status: 503, code: "ServerBusy" }); + const store = new BlobPayloadStore({ + connectionString, + credential: { getToken: async () => ({ token: "synthetic-token", expiresOnTimestamp: Date.now() + 3600000 }) }, + clientOptions: { httpClient: http, retryOptions: { maxTries: 2, secondaryHost: "account-secondary.invalid" } }, + }); + const pending = store.download("blob:v2:https://other.invalid/durabletask-payloads/blob"); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(hosts).toEqual(["other.invalid", "other.invalid"]); + }); + + it.each(["download", "delete"] as const)( + "rejects ambiguous secondary absence with its real cause when the native %s budget is exhausted", + async (operation) => { + const hosts: string[] = []; + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + hosts.push(new URL(request.url).host); + return send(request); + }; + http.failures.push({ status: 503, code: "ServerBusy" }, { status: 404, code: "BlobNotFound" }); + const pending = make(http, { maxTries: 2, secondaryHost: "secondary.invalid" })[operation](token); + const assertion = expect(pending).rejects.toMatchObject({ + code: RestError.REQUEST_SEND_ERROR, + cause: { statusCode: 404 }, + }); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + const failure: unknown = await pending.catch((error: unknown) => error); + if (!(failure instanceof RestError)) throw new Error("Expected an uncertain-primary transport error"); + expect(failure.statusCode).toBeUndefined(); + expect(failure).toMatchObject({ + cause: { request: { url: expect.stringContaining("secondary.invalid") }, statusCode: 404 }, + }); + expect(hosts).toEqual(["account.invalid", "secondary.invalid"]); + expect(http.requests).toHaveLength(2); + expect(http.requests.some((request) => request.method === "DELETE")).toBe(false); + }, + ); + + it("observes the raw SDK's secondary 404 at maxTries2 without claiming primary absence", async () => { + http.failures.push({ status: 503, code: "ServerBusy" }, { status: 404, code: "BlobNotFound" }); + const pending = new BlobClient(token.slice("blob:v2:".length), undefined, { + httpClient: http, + retryOptions: { maxTries: 2, secondaryHost: "secondary.invalid" }, + }).download(); + const assertion = expect(pending).rejects.toMatchObject({ + statusCode: 404, + request: { url: expect.stringContaining("secondary.invalid") }, + }); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + }); + + it.each(["download", "delete"] as const)( + "keeps a successful same-account secondary usable with maxTries2 for %s", + async (operation) => { + const hosts: string[] = []; + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + hosts.push(new URL(request.url).host); + return send(request); + }; + http.failures.push({ status: 503, code: "ServerBusy" }); + const pending = make(http, { maxTries: 2, secondaryHost: "secondary.invalid" })[operation](token); + const assertion = expect(pending).resolves.toBe( + operation === "download" ? "payload" : PayloadDeleteOutcome.Deleted, + ); + await jest.runAllTimersAsync(); + await assertion; + expect(hosts).toEqual( + operation === "download" + ? ["account.invalid", "secondary.invalid"] + : ["account.invalid", "secondary.invalid", "account.invalid"], + ); + }, + ); + + it.each(["download", "delete"] as const)( + "does not report absence after a primary transport error and final secondary 404 during %s", + async (operation) => { + const failure = Object.freeze(new RestError("primary socket failure", { code: "ECONNRESET" })); + http.failures.push(failure, { status: 404, code: "BlobNotFound" }); + const pending = make(http, { maxTries: 2, secondaryHost: "secondary.invalid" })[operation](token); + const assertion = expect(pending).rejects.toMatchObject({ + code: RestError.REQUEST_SEND_ERROR, + cause: { statusCode: 404 }, + }); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + }, + ); + + it.each(["download", "delete"] as const)( + "still confirms primary BlobNotFound without a secondary read for %s", + async (operation) => { + http.error = { status: 404, code: "BlobNotFound" }; + const pending = make(http, { maxTries: 2, secondaryHost: "secondary.invalid" })[operation](token); + const assertion = + operation === "delete" + ? expect(pending).resolves.toBe(PayloadDeleteOutcome.AlreadyAbsent) + : expect(pending).rejects.toBeInstanceOf(PayloadStorageException); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(1); + }, + ); + + it("confirms a subsequent primary 404 after the SDK stops using a lagging secondary", async () => { + http.failures.push( + { status: 503, code: "ServerBusy" }, + { status: 404, code: "BlobNotFound" }, + { status: 404, code: "BlobNotFound" }, + ); + const pending = make(http, { maxTries: 3, secondaryHost: "secondary.invalid" }).delete(token); + const assertion = expect(pending).resolves.toBe(PayloadDeleteOutcome.AlreadyAbsent); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(3); + }); + + it("uses the same retry boundary for credentialed cross-account and same-account reads", async () => { + const store = new BlobPayloadStore({ + connectionString, + credential: { getToken: async () => ({ token: "synthetic-token", expiresOnTimestamp: Date.now() + 3600000 }) }, + clientOptions: { + httpClient: http, + proxyOptions: { host: "http://127.0.0.1", port: 8080 }, + keepAliveOptions: { enable: false }, + userAgentOptions: { userAgentPrefix: "payload-resilience-test" }, + retryOptions: { maxTries: 2 }, + }, + }); + for (const endpoint of ["account.invalid/other/blob", "other.invalid/other/blob"]) { + http.failures.push({ status: 503, code: "ServerBusy" }); + const assertion = expect(store.download(`blob:v2:https://${endpoint}`)).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + } + expect(http.requests).toHaveLength(4); + for (const request of http.requests) { + expect(request.headers.get("user-agent")).toContain("payload-resilience-test"); + expect(request.headers.get("authorization")).toMatch(/^(SharedKey account:|Bearer synthetic-token)/); + expect(request.agent).toBeDefined(); + expect(request.keepAlive).toBe(true); + } + }); + + it.each([undefined, 0, 100.5])( + "passes the caller signal directly without injecting progress or a deadline (server timeout=%s)", + async (tryTimeoutInMs) => { + const controller = new AbortController(); + await expect(make(http, { tryTimeoutInMs }).download(token, controller.signal)).resolves.toBe("payload"); + expect(http.requests).toHaveLength(1); + const request = http.requests[0]; + expect(request.abortSignal).toBe(controller.signal); + expect(request.onDownloadProgress).toBeUndefined(); + expect(new URL(request.url).searchParams.get("timeout")).toBe( + tryTimeoutInMs === 0 ? null : String(Math.floor((tryTimeoutInMs ?? 120000) / 1000)), + ); + expect(jest.getTimerCount()).toBe(0); + }, + ); + + it("lets the SDK release a failed gzip body without adding timers or caller listeners", async () => { + let written = false; + const source = new Readable({ + read() { + if (!written) { + written = true; + this.push("notgzip"); + } + }, + }); + http.encoding = "gzip"; + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + const response = await send(request); + response.headers.set("content-length", "100"); + response.readableStreamBody = source; + return response; + }; + const controller = new AbortController(); + const pending = make(http, { maxTries: 1 }).download(token, controller.signal); + try { + await expect(pending).rejects.toMatchObject({ code: "Z_DATA_ERROR" }); + await jest.advanceTimersByTimeAsync(0); + expect({ + inputDestroyed: source.destroyed, + callerAborted: controller.signal.aborted, + callerListeners: getEventListeners(controller.signal, "abort").length, + timers: jest.getTimerCount(), + }).toEqual({ inputDestroyed: true, callerAborted: false, callerListeners: 0, timers: 0 }); + expect(http.requests).toHaveLength(1); + } finally { + controller.abort(); + source.destroy(); + await pending.catch(() => {}); + await jest.advanceTimersByTimeAsync(0); + } + }); + + it("removes timers and caller abort listeners after successful I/O", async () => { + const controller = new AbortController(); + const store = make(http); + const download = store.download(token, controller.signal); + await jest.runAllTimersAsync(); + await expect(download).resolves.toBe("payload"); + await expect(store.delete(token, controller.signal)).resolves.toBe(PayloadDeleteOutcome.Deleted); + expect(getEventListeners(controller.signal, "abort")).toHaveLength(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it("does not turn custom non-network failures into retries", async () => { + const failure = Object.freeze(new TypeError("invalid custom transport configuration")); + http.failures.push(failure); + await expect(make(http).download(token)).rejects.toBe(failure); + expect(http.requests).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + }); + + it("passes a long native server timeout hint without creating a client timer", async () => { + let actualSignal: WebResource["abortSignal"]; + http.sendRequest = async (request) => { + http.requests.push(request); + actualSignal = request.abortSignal; + return new Promise((_resolve, reject) => { + actualSignal!.addEventListener("abort", () => + reject(Object.assign(new Error("transport aborted"), { name: "AbortError" })), + ); + }); + }; + const controller = new AbortController(); + const reason = new Error("cancel long network timeout"); + const pending = make(http, { maxTries: 1, tryTimeoutInMs: 2147484000 }).download(token, controller.signal); + const rejected = pending.catch((error: unknown) => error); + await jest.advanceTimersByTimeAsync(1000); + expect(actualSignal!.aborted).toBe(false); + expect(actualSignal).toBe(controller.signal); + expect(new URL(http.requests[0].url).searchParams.get("timeout")).toBe("2147484"); + expect(http.requests).toHaveLength(1); + controller.abort(reason); + await jest.advanceTimersByTimeAsync(0); + expect(await rejected).toBe(reason); + expect(jest.getTimerCount()).toBe(0); + }); + + it("preserves finite fractional-millisecond retry and timeout options", async () => { + http.failures.push({ status: 503, code: "ServerBusy" }); + const pending = make(http, { + maxTries: 2, + retryDelayInMs: 10.5, + maxRetryDelayInMs: 20.5, + tryTimeoutInMs: 100.5, + }).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + expect(http.times[1] - http.times[0]).toBe(0); + }); + + it.each([ + "maxTries", + "retryDelayInMs", + "maxRetryDelayInMs", + "tryTimeoutInMs", + "retryPolicyType", + "secondaryHost", + ] as const)("accepts explicit undefined for optional %s", async (option) => { + const pending = make(http, { [option]: undefined }).download(token); + await jest.runAllTimersAsync(); + await expect(pending).resolves.toBe("payload"); + expect(jest.getTimerCount()).toBe(0); + }); + + it("leaves a transport's non-timeout AbortError terminal under the native SDK", async () => { + const failure = Object.freeze( + Object.assign(new Error("internal transport cancellation"), { name: "AbortError" }), + ); + http.failures.push(failure); + const controller = new AbortController(); + const pending = make(http).download(token, controller.signal); + const assertion = expect(pending).rejects.toBe(failure); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(1); + expect(controller.signal.aborted).toBe(false); + }); + + it("allows the SDK to resume a retryable body error using the original ETag", async () => { + const failure = Object.freeze(Object.assign(new Error("body disconnected"), { code: "ECONNRESET" })); + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + const response = await send(request); + if (http.requests.length === 1) { + response.readableStreamBody = new Readable({ + read() { + this.destroy(failure); + }, + }); + } + return response; + }; + const pending = make(http).download(token); + const assertion = expect(pending).resolves.toBe("payload"); + void assertion.catch(() => {}); + await jest.runAllTimersAsync(); + await assertion; + expect(http.requests).toHaveLength(2); + expect(http.requests[1].headers.get("if-match")).toBe('"owned-etag"'); + expect(http.requests[1].headers.get("range") ?? http.requests[1].headers.get("x-ms-range")).toBe("bytes=0-6"); + }); + + it("preserves the SDK's terminal body AbortError without normalizing or retrying it", async () => { + const failure = Object.freeze(Object.assign(new Error("native body aborted"), { name: "AbortError" })); + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + const response = await send(request); + response.readableStreamBody = new Readable({ + read() { + this.destroy(failure); + }, + }); + return response; + }; + await expect(make(http).download(token)).rejects.toBe(failure); + expect(http.requests).toHaveLength(1); + }); + + it("retains the SDK's separate five-resumption body budget", async () => { + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + const response = await send(request); + response.readableStreamBody = new Readable({ + read() { + this.destroy(new Error("interrupted SDK response")); + }, + }); + return response; + }; + await expect(make(http, { maxTries: 1 }).download(token)).rejects.toThrow("retries: 5, max retries: 5"); + expect(http.requests).toHaveLength(6); + for (const request of http.requests.slice(1)) { + expect(request.headers.get("if-match")).toBe('"owned-etag"'); + expect(request.headers.get("range") ?? request.headers.get("x-ms-range")).toBe("bytes=0-6"); + } + }); + + it.each([false, true])( + "resumes compressed UTF-8 bytes without changing ETag/offset (changed=%s)", + async (changed) => { + const text = "\u00e9\ud83d\ude80".repeat(200); + const bytes = gzipSync(Buffer.from(text)); + const split = 11; + http.encoding = "gzip"; + http.content = bytes; + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + const response = await send(request); + if (http.requests.length === 1) { + response.readableStreamBody = Readable.from( + (async function* () { + yield bytes.subarray(0, split); + throw new Error("interrupted compressed body"); + })(), + ); + } else if (changed) { + response.status = 412; + response.headers.set("x-ms-error-code", "ConditionNotMet"); + response.headers.set("content-type", "application/xml"); + response.readableStreamBody = undefined; + response.bodyAsText = "ConditionNotMetChanged ETag"; + } else { + response.status = 206; + response.headers.set("content-length", String(bytes.length - split)); + response.readableStreamBody = Readable.from([bytes.subarray(split)]); + } + return response; + }; + const pending = make(http).download(token); + if (changed) await expect(pending).rejects.toMatchObject({ statusCode: 412 }); + else await expect(pending).resolves.toBe(text); + expect(http.requests).toHaveLength(2); + expect(http.requests[1].headers.get("if-match")).toBe('"owned-etag"'); + expect(http.requests[1].headers.get("range") ?? http.requests[1].headers.get("x-ms-range")).toBe( + `bytes=${split}-${bytes.length - 1}`, + ); + }, + ); + + it.each(["AbortError", "ETIMEDOUT"])("never retries a caller's retryable-looking %s abort reason", async (kind) => { + const controller = new AbortController(); + const reason = Object.freeze(Object.assign(new Error("caller cancellation"), { name: kind, code: "ETIMEDOUT" })); + http.sendRequest = async (request) => { + http.requests.push(request); + controller.abort(reason); + throw reason; + }; + await expect(make(http).download(token, controller.signal)).rejects.toBe(reason); + expect(http.requests).toHaveLength(1); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each(["BlobNotFound", "ContainerNotFound"])( + "does not convert a caller abort shaped like %s into deletion success", + async (code) => { + const controller = new AbortController(); + const reason = Object.freeze( + Object.assign(new Error("caller abort with a storage-shaped reason"), { code, statusCode: 404 }), + ); + http.sendRequest = async (request) => { + http.requests.push(request); + controller.abort(reason); + throw reason; + }; + await expect(make(http).delete(token, controller.signal)).rejects.toBe(reason); + expect(http.requests).toHaveLength(1); + }, + ); + + it("does not invalidate a healthy container generation for a storage-shaped caller abort", async () => { + const store = make(http); + await store.upload("warm container"); + const controller = new AbortController(); + const reason = Object.freeze( + Object.assign(new Error("caller abort"), { code: "ContainerNotFound", statusCode: 404 }), + ); + const send = http.sendRequest.bind(http); + let abortNext = true; + http.sendRequest = async (request) => { + if (abortNext && !request.url.includes("restype=container")) { + abortNext = false; + http.requests.push(request); + controller.abort(reason); + throw reason; + } + return send(request); + }; + await expect(store.upload("canceled", controller.signal)).rejects.toBe(reason); + await store.upload("after cancellation"); + expect(http.requests.filter((request) => request.url.includes("restype=container"))).toHaveLength(1); + }); + }); + const make = (http: RecordingClient, options: Partial[0]> = {}) => + new BlobPayloadStore({ + connectionString, + clientOptions: { httpClient: http, retryOptions: { maxTries: 1 } }, + ...options, + }); + + it.each([true, false])( + "writes credential-free v2 GUID tokens and ownership metadata (gzip=%s)", + async (compressionEnabled) => { + const store = make(http, { compressionEnabled }); + const token = await store.upload("\u00e9\ud83d\ude80"); + expect(token).toMatch(/^blob:v2:https:\/\/account\.invalid\/durabletask-payloads\/[a-f0-9]{32}$/); + const upload = http.requests.find((request) => request.method === "PUT" && !request.url.includes("restype="))!; + expect(upload.headers.get("x-ms-meta-managed_by")).toBe("dts"); + expect(upload.headers.get("x-ms-blob-content-encoding") ?? "").toBe(compressionEnabled ? "gzip" : ""); + expect((compressionEnabled ? gunzipSync(http.bodies[0]) : http.bodies[0]).toString("utf8")).toBe( + "\u00e9\ud83d\ude80", + ); + expect(http.requests[0].headers.get("x-ms-blob-public-access")).toBeUndefined(); + }, + ); + + it("single-flights container initialization and recovers one deleted-container generation", async () => { + const store = make(http); + await Promise.all(Array.from({ length: 12 }, () => store.upload("data"))); + expect(http.requests.filter((r) => r.url.includes("restype=container"))).toHaveLength(1); + http.failFirstUpload = true; + await store.upload("recovered"); + expect(http.requests.filter((r) => r.url.includes("restype=container"))).toHaveLength(2); + expect(http.bodies).toHaveLength(13); + }); + + it("cancels a waiting initializer promptly without canceling or replacing the leader", async () => { + let release!: () => void; + let entered!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + const send = http.sendRequest.bind(http); + http.sendRequest = async (request) => { + if (request.url.includes("restype=container")) { + entered(); + await blocked; + } + return send(request); + }; + const store = make(http, { compressionEnabled: false }); + const leader = new AbortController(); + const waiter = new AbortController(); + const removed = jest.spyOn(waiter.signal, "removeEventListener"); + const first = store.upload("first", leader.signal); + await started; + const reason = new Error("Only cancel the waiter"); + let observed: unknown; + const second = store.upload("second", waiter.signal).catch((error) => { + observed = error; + }); + try { + waiter.abort(reason); + await new Promise((resolve) => setImmediate(resolve)); + expect(observed).toBe(reason); + expect(leader.signal.aborted).toBe(false); + expect(removed).toHaveBeenCalledWith("abort", expect.any(Function)); + release(); + await first; + await store.upload("third"); + expect(http.requests.filter((r) => r.url.includes("restype=container"))).toHaveLength(1); + expect(http.bodies.map((body) => body.toString())).toEqual(["first", "third"]); + } finally { + release(); + await Promise.allSettled([first, second]); + } + }); + + it("lets a surviving waiter initialize after the previous owner is canceled", async () => { + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const send = http.sendRequest.bind(http); + let firstContainerRequest = true; + http.sendRequest = async (request) => { + if (request.url.includes("restype=container") && firstContainerRequest) { + firstContainerRequest = false; + if (!request.abortSignal) throw new Error("The owner must propagate cancellation."); + const signal = request.abortSignal; + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => + reject(Object.assign(new Error("Owner canceled"), { name: "AbortError" })), + ); + entered(); + }); + } + return send(request); + }; + const owner = new AbortController(); + const store = make(http, { compressionEnabled: false }); + const first = store.upload("owner", owner.signal); + const failedOwner = expect(first).rejects.toThrow(); + await started; + const survivor = store.upload("survivor"); + owner.abort(); + await failedOwner; + await expect(survivor).resolves.toMatch(/^blob:v2:/); + expect(http.bodies.map((body) => body.toString())).toEqual(["survivor"]); + }); + + it.each(["gzip", "GZip", ""])("reads UTF-8 storage representation with encoding %s", async (encoding) => { + http.content = encoding ? gzipSync(Buffer.from("\u00e9\ud83d\ude80")) : Buffer.from("\u00e9\ud83d\ude80"); + http.encoding = encoding; + expect(await make(http).download("blob:v1:durabletask-payloads:folder/a b%2Fc")).toBe("\u00e9\ud83d\ude80"); + expect(new URL(http.requests[0].url).pathname).toBe("/durabletask-payloads/folder/a%20b%252Fc"); + }); + + it.each(fixtures.vectors)("reads actual .NET 10 GZipStream fixture $id", async (fixture) => { + http.encoding = "gzip"; + http.content = Buffer.from(fixture.gzipBase64, "base64"); + const expected = fixture.seed.repeat(fixture.repeat) + fixture.suffix; + const actual = await make(http).download("blob:v2:https://account.invalid/durabletask-payloads/dotnet"); + expect(actual).toBe(expected); + expect(Buffer.byteLength(actual, "utf8")).toBe(fixture.utf8Bytes); + }); + + it.each([ + ["https://account.invalid", "https://account.invalid/old/folder/a%20b%252Fc"], + ["http://127.0.0.1:10000/accounta", "http://127.0.0.1:10000/accounta/old/blob"], + ])("preserves configured SharedKey pipeline across containers in %s", async (endpoint, target) => { + const store = make(http, { + connectionString: `DefaultEndpointsProtocol=http;AccountName=account;AccountKey=${accountKey};BlobEndpoint=${endpoint}`, + }); + expect(await store.download(`blob:v2:${target}`)).toBe("payload"); + expect(await store.delete(`blob:v2:${target}`)).toBe(PayloadDeleteOutcome.Deleted); + expect(http.requests.map((r) => r.method)).toEqual(["GET", "HEAD", "DELETE"]); + for (const request of http.requests) { + const url = new URL(request.url); + expect(url.origin + url.pathname).toBe(target); + expect(url.searchParams.get("timeout")).toBe("120"); + expect(request.headers.get("authorization")).toMatch(/^SharedKey /); + } + expect(http.requests[2].headers.get("if-match")).toBe('"owned-etag"'); + expect(http.requests[2].headers.get("x-ms-delete-snapshots")).toBe("include"); + }); + + it.each([ + "https://other.invalid/old/blob", + "http://account.invalid/old/blob", + "https://account.invalid:444/old/blob", + "http://127.0.0.1:10000/accountb/old/blob", + ])("never forwards configured account keys to %s", async (target) => { + const store = make(http); + await expect(store.download(`blob:v2:${target}`)).rejects.toBeInstanceOf(PayloadStorageException); + await expect(store.delete(`blob:v2:${target}`)).rejects.toBeInstanceOf(PayloadStorageException); + expect(http.requests).toHaveLength(0); + }); + + it("uses identity, not the configured account key, for cross-account reads", async () => { + const credential = { + getToken: async () => ({ token: "synthetic-access-token", expiresOnTimestamp: Date.now() + 3600000 }), + }; + const store = make(http, { credential }); + expect(await store.download("blob:v2:https://other.invalid/old/blob")).toBe("payload"); + expect(http.requests[0].headers.get("authorization")).toBe("Bearer synthetic-access-token"); + }); + + it("supports account URI and identity without a connection string", async () => { + const store = make(http, { + connectionString: undefined, + accountUri: "https://account.invalid", + credential: { + getToken: async () => ({ token: "synthetic-access-token", expiresOnTimestamp: Date.now() + 3600000 }), + }, + }); + expect(await store.download("blob:v2:https://account.invalid/durabletask-payloads/blob")).toBe("payload"); + expect(http.requests[0].headers.get("authorization")).toBe("Bearer synthetic-access-token"); + }); + + it("classifies the service endpoint independently of normalized blob-name escaping", async () => { + expect(await make(http).download("blob:v2:https://account.invalid/old/folder%2fblob")).toBe("payload"); + expect(http.requests[0].headers.get("authorization")).toMatch(/^SharedKey /); + expect(new URL(http.requests[0].url).pathname).toBe("/old/folder/blob"); + }); + + it("rejects another path-style account on the same host without sending SharedKey", async () => { + const store = make(http, { + connectionString: `DefaultEndpointsProtocol=http;AccountName=accounta;AccountKey=${accountKey};BlobEndpoint=http://127.0.0.1:10000/accounta`, + }); + await expect(store.download("blob:v2:http://127.0.0.1:10000/accountb/old/blob")).rejects.toBeInstanceOf( + PayloadStorageException, + ); + expect(http.requests).toHaveLength(0); + }); + + it("preserves a configured SAS across same-account containers without persisting it", async () => { + const store = make(http, { + connectionString: + "AccountName=account;SharedAccessSignature=sv=2025-01-05&sr=c&sp=rwd&sig=synthetic;BlobEndpoint=https://account.invalid", + }); + const token = await store.upload("payload"); + expect(token).not.toContain("sig="); + await store.download("blob:v2:https://account.invalid/old/blob"); + expect(new URL(http.requests.at(-1)!.url).searchParams.get("sig")).toBe("synthetic"); + }); + + it.each([ + [{}, PayloadDeleteOutcome.NotStoreOwned], + [{ managed_by: "customer" }, PayloadDeleteOutcome.NotStoreOwned], + [{ MANAGED_BY: "dts" }, PayloadDeleteOutcome.Deleted], + ])("checks ownership case-insensitively before conditional deletion (%#)", async (metadata, outcome) => { + http.metadata = metadata; + expect(await make(http).delete("blob:v2:https://account.invalid/durabletask-payloads/blob")).toBe(outcome); + expect(http.requests.some((r) => r.method === "DELETE")).toBe(outcome === PayloadDeleteOutcome.Deleted); + }); + + it.each(["BlobNotFound", "ContainerNotFound"])("confirms absence only for %s", async (code) => { + http.error = { status: 404, code }; + expect(await make(http).delete("blob:v2:https://account.invalid/durabletask-payloads/blob")).toBe( + PayloadDeleteOutcome.AlreadyAbsent, + ); + await expect( + make(http).download("blob:v2:https://account.invalid/durabletask-payloads/blob"), + ).rejects.toBeInstanceOf(PayloadStorageException); + }); + + it.each([ + [404, ""], + [412, "ConditionNotMet"], + [403, "AuthorizationPermissionMismatch"], + ])("propagates uncertain storage failures %s/%s", async (status, code) => { + http.error = { status, code }; + await expect(make(http).delete("blob:v2:https://account.invalid/durabletask-payloads/blob")).rejects.toMatchObject({ + statusCode: status, + }); + }); + + it.each([ + "blob:v1:bad", + "blob:v1:other:blob", + "blob:v2:invalid", + "blob:v2:https://account.invalid/container/", + "blob:v3:future", + ])("rejects malformed or mismatched tokens without storage I/O (%s)", async (token) => { + await expect(make(http).download(token)).rejects.toBeInstanceOf(TypeError); + expect(http.requests).toHaveLength(0); + }); +}); diff --git a/packages/durabletask-js-azureblob/test/dotnet-payload-fixtures.json b/packages/durabletask-js-azureblob/test/dotnet-payload-fixtures.json new file mode 100644 index 00000000..514e22aa --- /dev/null +++ b/packages/durabletask-js-azureblob/test/dotnet-payload-fixtures.json @@ -0,0 +1,91 @@ +{ + "referenceCommit": "91739687ff647699079cdadd627daa00562982b7", + "runtime": ".NET 10.0.13", + "compression": "System.IO.Compression.GZipStream / CompressionLevel.Optimal", + "vectors": [ + { + "id": "ascii-below-threshold", + "seed": "a", + "repeat": 262143, + "suffix": "", + "utf8Bytes": 262143, + "utf16CodeUnits": 262143, + "sha256": "0379854ae435b4c82999632f674694ce1f98103f645f44ba68c901a83a2b00fd", + "gzipBase64": "H4sIAAAAAAAACuzBMQEAAADCoKzrX8LbDkABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJwBAAD//wMAs7HRUv//AwA=", + "requiresExternalization": false, + "exceedsMaximum": false + }, + { + "id": "ascii-at-threshold", + "seed": "a", + "repeat": 262144, + "suffix": "", + "utf8Bytes": 262144, + "utf16CodeUnits": 262144, + "sha256": "dd3dde87623d9a6b354c68c943d189c89c63652d945e7bbdf0986cae91a49521", + "gzipBase64": "H4sIAAAAAAAACuzBMQEAAADCoKzrX8LbDkABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALwBAAD//wMAxI2NugAABAA=", + "requiresExternalization": true, + "exceedsMaximum": false + }, + { + "id": "ascii-above-threshold", + "seed": "a", + "repeat": 262145, + "suffix": "", + "utf8Bytes": 262145, + "utf16CodeUnits": 262145, + "sha256": "c592f4a6b099700b5050ce8bc67367f0c8f44810203124e86405c3e7b6f1a2ba", + "gzipBase64": "H4sIAAAAAAAACuzBMQEAAADCoKzrX8LfDEABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJwBAAD//wMAZzUEdAEABAA=", + "requiresExternalization": true, + "exceedsMaximum": false + }, + { + "id": "multibyte-at-threshold", + "seed": "é", + "repeat": 131072, + "suffix": "", + "utf8Bytes": 262144, + "utf16CodeUnits": 131072, + "sha256": "94914398e4fe14ac182b9e6080caa078bbde122682c352744929d55f7d038d10", + "gzipBase64": "H4sIAAAAAAAACuzQwRAAAAACMH+UOBLrncNuBmuKAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYMCAAQMGDBgwYOAGBgAA//8DAONphUEAAAQA", + "requiresExternalization": true, + "exceedsMaximum": false + }, + { + "id": "supplementary-at-threshold", + "seed": "🚀", + "repeat": 65536, + "suffix": "", + "utf8Bytes": 262144, + "utf16CodeUnits": 131072, + "sha256": "f802f2cdafbd3f25ef8ce8e48994ef484a1b860addef41067f94e621945eac6e", + "gzipBase64": "H4sIAAAAAAAACuzSMREAAAgDMevoYMFiJXRFRIY38JfcTuQBAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDDAAAMMMMAAAwwwwAADDDwDBQAA//8DAIXOWJsAAAQA", + "requiresExternalization": true, + "exceedsMaximum": false + }, + { + "id": "ascii-at-maximum", + "seed": "a", + "repeat": 10485760, + "suffix": "", + "utf8Bytes": 10485760, + "utf16CodeUnits": 10485760, + "sha256": "b5eec3f68ef64d15e82dad91ff908582c5f081e61a62e22427af9bec2cd35f8d", + "gzipBase64": "H4sIAAAAAAAACuzBgQAAAACAINb9JRapCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgNmDAwEAAAAAIP/XRlBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVhD44JAAAAEAZltX+JxfABBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3AQAA//8DALIc12sAAKAA", + "requiresExternalization": true, + "exceedsMaximum": false + }, + { + "id": "ascii-above-maximum", + "seed": "a", + "repeat": 10485761, + "suffix": "", + "utf8Bytes": 10485761, + "utf16CodeUnits": 10485761, + "sha256": "4ea73dbccbce283083f78555e86595e0b345c46ff188509412fee1c68914d0cb", + "gzipBase64": "H4sIAAAAAAAACuzBgQAAAACAINb9JRapCgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgNmDAwEAAAAAIP/XRlBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVhD44JAAAAEAZltX+JxfABBgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AQAA//8DAP+7s80BAKAA", + "requiresExternalization": true, + "exceedsMaximum": true + } + ] +} diff --git a/packages/durabletask-js-azureblob/test/fixtures/active-download.js b/packages/durabletask-js-azureblob/test/fixtures/active-download.js new file mode 100644 index 00000000..df6beca5 --- /dev/null +++ b/packages/durabletask-js-azureblob/test/fixtures/active-download.js @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const path = require("node:path"); +const { channel } = require("node:diagnostics_channel"); +const { getEventListeners } = require("node:events"); +const swc = require("@swc/core"); +const { compilerOptions } = require("../../../../tsconfig.base.json"); + +require("ts-node").register({ + project: path.join(__dirname, "..", "..", "tsconfig.json"), + transpileOnly: true, + transpiler: [ + "ts-node/transpilers/swc", + { + swc: { + transformSync(source, options) { + return swc.transformSync(source, { + ...options, + jsc: { + ...options.jsc, + baseUrl: path.join(__dirname, "../../../.."), + paths: compilerOptions.paths, + }, + }); + }, + }, + }, + ], +}); +const { BlobPayloadStore } = require("../../src/blob-payload-store.ts"); +const endpoint = process.argv[2]; +const controller = new AbortController(); +const reason = Object.freeze(new Error("caller canceled an active download")); +const accountKey = Buffer.alloc(64).toString("base64"); +let operation; +let result; +let raw; +let established; +const responses = channel("http.client.response.finish"); +responses.subscribe(({ response }) => { + if (raw) return; + raw = response; + raw.once("data", (chunk) => { + established = setTimeout(() => { + process.send({ type: "body", bytes: chunk.length, complete: raw.complete }); + }, 25); + }); + process.send({ type: "headers" }); +}); +process.on("message", (message) => { + if (message === "abort") controller.abort(reason); + if (message === "observe") { + process.send({ + type: "observation", + result, + callerAborted: controller.signal.aborted, + callerListeners: getEventListeners(controller.signal, "abort").length, + rawClosed: raw.closed, + }); + } + if (message === "cleanup") { + clearTimeout(established); + controller.abort(new Error("test cleanup after observation")); + void operation.then(() => process.disconnect()); + } +}); +const store = new BlobPayloadStore({ + connectionString: `DefaultEndpointsProtocol=http;AccountName=account;AccountKey=${accountKey};BlobEndpoint=${endpoint}`, + clientOptions: { retryOptions: { maxTries: 1, tryTimeoutInMs: 0 } }, +}); +process.send({ type: "ready" }); +operation = store.download(`blob:v2:${endpoint}/durabletask-payloads/blob`, controller.signal).then( + () => { + result = { outcome: "resolved" }; + }, + (error) => { + result = { + outcome: "rejected", + originalReason: error === reason, + name: error.name, + code: error.code, + }; + }, +); diff --git a/packages/durabletask-js-azureblob/test/local-azurite-client.spec.ts b/packages/durabletask-js-azureblob/test/local-azurite-client.spec.ts new file mode 100644 index 00000000..35bcb0ab --- /dev/null +++ b/packages/durabletask-js-azureblob/test/local-azurite-client.spec.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IHttpClient } from "@azure/storage-blob"; +import { createLocalAzuriteClient } from "./local-azurite-client"; + +const prefix = `DefaultEndpointsProtocol=https;AccountName=account;AccountKey=${Buffer.alloc(64).toString("base64")};`; + +describe("Azurite effective local-only endpoint guard", () => { + const sendRequest = jest.fn, Parameters>( + async () => { + throw new Error("No HTTP is permitted in endpoint guard tests."); + }, + ); + const options = { httpClient: { sendRequest } }; + + afterEach(() => { + expect(sendRequest).not.toHaveBeenCalled(); + sendRequest.mockClear(); + }); + + it.each([undefined, ""])("keeps the opt-in suite disabled without a connection string (%s)", (value) => { + expect(createLocalAzuriteClient(value, options)).toBeUndefined(); + }); + + it.each(["localhost", "127.0.0.1", "[::1]"])("allows a parsed explicit %s endpoint without I/O", (host) => { + const client = createLocalAzuriteClient(`${prefix}BlobEndpoint=http://${host}:10000/account`, options); + expect(new URL(client!.url).hostname).toBe(host); + }); + + it("uses the SDK's first repeated propercase endpoint, not the last", () => { + const client = createLocalAzuriteClient( + `${prefix}BlobEndpoint=http://localhost:10000/account;BlobEndpoint=https://remote.invalid`, + options, + ); + expect(new URL(client!.url).hostname).toBe("localhost"); + }); + + it("allows whitespace before an explicit canonical local endpoint as the SDK does", () => { + const client = createLocalAzuriteClient(`${prefix} BlobEndpoint=http://localhost:10000/account`, options); + expect(new URL(client!.url).hostname).toBe("localhost"); + }); + + it("rejects a lowercase local hint when the SDK actually selects a remote endpoint", () => { + expect(() => + createLocalAzuriteClient( + `${prefix}blobendpoint=http://localhost:10000/account;BlobEndpoint=https://remote.invalid`, + options, + ), + ).toThrow("explicit local-only BlobEndpoint"); + }); + + it("rejects an actually remote first propercase endpoint even if a later endpoint is local", () => { + expect(() => + createLocalAzuriteClient( + `${prefix}BlobEndpoint=https://remote.invalid;BlobEndpoint=http://localhost:10000/account`, + options, + ), + ).toThrow("explicit local-only BlobEndpoint"); + }); + + it("requires an explicit endpoint rather than an implicit development-storage shortcut", () => { + expect(() => createLocalAzuriteClient("UseDevelopmentStorage=true", options)).toThrow( + "explicit local-only BlobEndpoint", + ); + }); +}); diff --git a/packages/durabletask-js-azureblob/test/local-azurite-client.ts b/packages/durabletask-js-azureblob/test/local-azurite-client.ts new file mode 100644 index 00000000..2813e2fc --- /dev/null +++ b/packages/durabletask-js-azureblob/test/local-azurite-client.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { BlobServiceClient, StoragePipelineOptions } from "@azure/storage-blob"; + +export function createLocalAzuriteClient( + connectionString?: string, + options?: StoragePipelineOptions, +): BlobServiceClient | undefined { + if (!connectionString) return undefined; + const service = BlobServiceClient.fromConnectionString(connectionString, options); + if ( + !/(?:^|;)\s*BlobEndpoint=/.test(connectionString) || + !["127.0.0.1", "localhost", "[::1]"].includes(new URL(service.url).hostname) + ) { + throw new Error("Azurite tests require an explicit local-only BlobEndpoint."); + } + return service; +} diff --git a/packages/durabletask-js-azureblob/tsconfig.build.json b/packages/durabletask-js-azureblob/tsconfig.build.json new file mode 100644 index 00000000..b4fc9d03 --- /dev/null +++ b/packages/durabletask-js-azureblob/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": ".", + "paths": { "@microsoft/durabletask-js": ["../durabletask-js/dist/index"] } + } +} diff --git a/packages/durabletask-js-azureblob/tsconfig.json b/packages/durabletask-js-azureblob/tsconfig.json new file mode 100644 index 00000000..8acab8b6 --- /dev/null +++ b/packages/durabletask-js-azureblob/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 1dfb5658..20ccaaa1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,6 +14,7 @@ "paths": { "@microsoft/durabletask-js": ["packages/durabletask-js/src/index.ts"], "@microsoft/durabletask-js-azuremanaged": ["packages/durabletask-js-azuremanaged/src/index.ts"], + "@microsoft/durabletask-js-azureblob": ["packages/durabletask-js-azureblob/src/index.ts"], "@microsoft/durabletask-js-export-history": ["packages/durabletask-js-export-history/src/index.ts"] } }