@stacksjs/ts-cloud 0.2.22 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export { AWSClient, CloudFormationClient, CloudFormationClient as AWSCloudFormat
6
6
  export type { AWSRequestOptions, AWSClientConfig, AWSError, AWSCredentials as AWSClientCredentials, StackParameter, StackTag, CreateStackOptions, UpdateStackOptions, DescribeStacksOptions, StackEvent, Stack, InvalidationOptions, Distribution, S3SyncOptions, S3CopyOptions, S3ListOptions, S3Object, CertificateDetail, Certificate as ELBv2Certificate, RekognitionS3Object, RekognitionBoundingBox, TextractS3Object, TextractBoundingBox, CountryCode, ContactType, ContactDetail, KendraCreateDataSourceCommandInput, KendraCreateDataSourceCommandOutput, KendraListDataSourcesCommandInput, KendraListDataSourcesCommandOutput, InvokeModelCommandInput, InvokeModelCommandOutput, InvokeModelWithResponseStreamCommandInput, InvokeModelWithResponseStreamCommandOutput, CreateModelCustomizationJobCommandInput, CreateModelCustomizationJobCommandOutput, GetModelCustomizationJobCommandInput, GetModelCustomizationJobCommandOutput, ListFoundationModelsCommandInput, ListFoundationModelsCommandOutput, AttributeValue as DynamoDBAttributeValue, KeySchemaElement, AttributeDefinition as DynamoDBAttributeDefinition, } from './aws';
7
7
  export { createObjectStorageClient, providerEndpoint, resolveObjectStorage, } from './object-storage';
8
8
  export type { ObjectStorageConfig, ObjectStorageCredentials, ObjectStorageProvider, ResolvedObjectStorage, } from './object-storage';
9
+ export { keyMatchesFilters, migrateObjectStorage, remapKey, } from './object-storage/migrate';
10
+ export type { MigrateEndpoint, MigrateError, MigrateOptions, MigratePlanItem, MigrateProgress, MigrateResult, MigrateVerification, } from './object-storage/migrate';
9
11
  export * from './ssl';
10
12
  export { deployStaticSite, deployStaticSiteFull, uploadStaticFiles, invalidateCache, deleteStaticSite, generateStaticSiteTemplate, deployStaticSiteWithExternalDns, deployStaticSiteWithExternalDnsFull, generateExternalDnsStaticSiteTemplate, deploySite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, } from './deploy';
11
13
  export type { StaticSiteConfig, DeployResult, UploadOptions, ExternalDnsStaticSiteConfig, ExternalDnsDeployResult, DeploySiteConfig, DeploySiteResult, StaticSiteDnsProvider, SiteDeployKind, DeploymentValidationResult, } from './deploy';
package/dist/index.js CHANGED
@@ -2123,6 +2123,67 @@ class S3Client2 {
2123
2123
  });
2124
2124
  return result;
2125
2125
  }
2126
+ async getObjectBytes(bucket, key) {
2127
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials();
2128
+ const host = this.s3VirtualHost(bucket);
2129
+ const encodedKey = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
2130
+ const canonicalUri = this.forcePathStyle ? `/${bucket}/${encodedKey}` : `/${encodedKey}`;
2131
+ const url = `https://${host}${canonicalUri}`;
2132
+ const now = new Date;
2133
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
2134
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, "");
2135
+ const payloadHash = crypto3.createHash("sha256").update("").digest("hex");
2136
+ const requestHeaders = {
2137
+ host,
2138
+ "x-amz-date": amzDate,
2139
+ "x-amz-content-sha256": payloadHash
2140
+ };
2141
+ if (sessionToken) {
2142
+ requestHeaders["x-amz-security-token"] = sessionToken;
2143
+ }
2144
+ const canonicalHeaders = Object.keys(requestHeaders).sort().map((k) => `${k.toLowerCase()}:${requestHeaders[k].trim()}
2145
+ `).join("");
2146
+ const signedHeaders = Object.keys(requestHeaders).sort().map((k) => k.toLowerCase()).join(";");
2147
+ const canonicalRequest = [
2148
+ "GET",
2149
+ canonicalUri,
2150
+ "",
2151
+ canonicalHeaders,
2152
+ signedHeaders,
2153
+ payloadHash
2154
+ ].join(`
2155
+ `);
2156
+ const algorithm = "AWS4-HMAC-SHA256";
2157
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`;
2158
+ const stringToSign = [
2159
+ algorithm,
2160
+ amzDate,
2161
+ credentialScope,
2162
+ crypto3.createHash("sha256").update(canonicalRequest).digest("hex")
2163
+ ].join(`
2164
+ `);
2165
+ const kDate = crypto3.createHmac("sha256", `AWS4${secretAccessKey}`).update(dateStamp).digest();
2166
+ const kRegion = crypto3.createHmac("sha256", kDate).update(this.region).digest();
2167
+ const kService = crypto3.createHmac("sha256", kRegion).update("s3").digest();
2168
+ const kSigning = crypto3.createHmac("sha256", kService).update("aws4_request").digest();
2169
+ const signature = crypto3.createHmac("sha256", kSigning).update(stringToSign).digest("hex");
2170
+ const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
2171
+ const response = await fetch(url, {
2172
+ method: "GET",
2173
+ headers: { ...requestHeaders, Authorization: authorizationHeader }
2174
+ });
2175
+ if (!response.ok) {
2176
+ const errorText = await response.text();
2177
+ throw new Error(`S3 GET failed: ${response.status} ${errorText}`);
2178
+ }
2179
+ const buffer = await response.arrayBuffer();
2180
+ const contentLengthHeader = response.headers.get("content-length");
2181
+ return {
2182
+ body: new Uint8Array(buffer),
2183
+ contentType: response.headers.get("content-type") ?? undefined,
2184
+ contentLength: contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : undefined
2185
+ };
2186
+ }
2126
2187
  async copyObject(options) {
2127
2188
  const headers = {
2128
2189
  "x-amz-copy-source": `/${options.sourceBucket}/${options.sourceKey}`
@@ -65570,6 +65631,165 @@ init_client();
65570
65631
 
65571
65632
  // src/object-storage/index.ts
65572
65633
  init_s3();
65634
+
65635
+ // src/object-storage/migrate.ts
65636
+ function stripPrefix(key, prefix) {
65637
+ if (!prefix)
65638
+ return key;
65639
+ return key.startsWith(prefix) ? key.slice(prefix.length) : key;
65640
+ }
65641
+ function remapKey(sourceKey, fromPrefix, toPrefix) {
65642
+ const stripped = stripPrefix(sourceKey, fromPrefix);
65643
+ return `${toPrefix ?? ""}${stripped}`;
65644
+ }
65645
+ function keyMatchesFilters(key, include, exclude) {
65646
+ if (exclude && exclude.some((p) => key.startsWith(p)))
65647
+ return false;
65648
+ if (include && include.length > 0)
65649
+ return include.some((p) => key.startsWith(p));
65650
+ return true;
65651
+ }
65652
+ async function mapWithConcurrency(items, limit, worker) {
65653
+ let cursor = 0;
65654
+ const runners = [];
65655
+ const size = Math.max(1, Math.min(limit, items.length || 1));
65656
+ for (let i = 0;i < size; i++) {
65657
+ runners.push((async () => {
65658
+ while (true) {
65659
+ const index = cursor++;
65660
+ if (index >= items.length)
65661
+ return;
65662
+ await worker(items[index], index);
65663
+ }
65664
+ })());
65665
+ }
65666
+ await Promise.all(runners);
65667
+ }
65668
+ function clientFor(endpoint) {
65669
+ if (endpoint.client)
65670
+ return endpoint.client;
65671
+ return createObjectStorageClient({
65672
+ provider: endpoint.provider,
65673
+ region: endpoint.region,
65674
+ endpoint: endpoint.endpoint,
65675
+ forcePathStyle: endpoint.forcePathStyle,
65676
+ credentials: endpoint.credentials
65677
+ });
65678
+ }
65679
+ async function migrateObjectStorage(options) {
65680
+ const concurrency = options.concurrency ?? 8;
65681
+ const fromClient = clientFor(options.from);
65682
+ const toClient = clientFor(options.to);
65683
+ const sourceObjects = await fromClient.listAllObjects({
65684
+ bucket: options.from.bucket,
65685
+ prefix: options.from.prefix
65686
+ });
65687
+ const result = {
65688
+ copied: 0,
65689
+ skipped: 0,
65690
+ excluded: 0,
65691
+ bytesCopied: 0,
65692
+ errors: [],
65693
+ excludedKeys: [],
65694
+ deleted: []
65695
+ };
65696
+ const toCopy = [];
65697
+ for (const obj of sourceObjects) {
65698
+ if (!keyMatchesFilters(obj.Key, options.include, options.exclude)) {
65699
+ result.excluded++;
65700
+ result.excludedKeys.push(obj.Key);
65701
+ continue;
65702
+ }
65703
+ toCopy.push({ source: obj, destKey: remapKey(obj.Key, options.from.prefix, options.to.prefix) });
65704
+ }
65705
+ if (options.dryRun) {
65706
+ result.plan = toCopy.map(({ source, destKey }) => ({ key: source.Key, destKey, size: source.Size }));
65707
+ const total2 = sourceObjects.length;
65708
+ let index = 0;
65709
+ for (const obj of sourceObjects) {
65710
+ index++;
65711
+ const excluded = !keyMatchesFilters(obj.Key, options.include, options.exclude);
65712
+ options.onProgress?.({
65713
+ key: obj.Key,
65714
+ destKey: excluded ? "" : remapKey(obj.Key, options.from.prefix, options.to.prefix),
65715
+ size: obj.Size,
65716
+ action: excluded ? "excluded" : "planned",
65717
+ index,
65718
+ total: total2
65719
+ });
65720
+ }
65721
+ return result;
65722
+ }
65723
+ const total = toCopy.length;
65724
+ let processed = 0;
65725
+ await mapWithConcurrency(toCopy, concurrency, async ({ source, destKey }) => {
65726
+ const myIndex = ++processed;
65727
+ try {
65728
+ if (!options.force) {
65729
+ const head = await toClient.headObject(options.to.bucket, destKey);
65730
+ if (head && head.ContentLength === source.Size) {
65731
+ result.skipped++;
65732
+ options.onProgress?.({ key: source.Key, destKey, size: source.Size, action: "skipped", index: myIndex, total });
65733
+ return;
65734
+ }
65735
+ }
65736
+ const { body, contentType } = await fromClient.getObjectBytes(options.from.bucket, source.Key);
65737
+ await toClient.putObject({
65738
+ bucket: options.to.bucket,
65739
+ key: destKey,
65740
+ body,
65741
+ contentType
65742
+ });
65743
+ result.copied++;
65744
+ result.bytesCopied += body.byteLength;
65745
+ options.onProgress?.({ key: source.Key, destKey, size: source.Size, action: "copied", index: myIndex, total });
65746
+ } catch (err) {
65747
+ result.errors.push({ key: source.Key, message: err?.message ?? String(err) });
65748
+ options.onProgress?.({ key: source.Key, destKey, size: source.Size, action: "error", index: myIndex, total });
65749
+ }
65750
+ });
65751
+ const copiedDestKeys = new Set(toCopy.map((c) => c.destKey));
65752
+ if (options.deleteExtraneous) {
65753
+ const destObjects = await toClient.listAllObjects({ bucket: options.to.bucket, prefix: options.to.prefix });
65754
+ const extraneous = destObjects.filter((o) => !copiedDestKeys.has(o.Key)).map((o) => o.Key);
65755
+ for (const key of extraneous) {
65756
+ try {
65757
+ await toClient.deleteObject(options.to.bucket, key);
65758
+ result.deleted.push(key);
65759
+ } catch (err) {
65760
+ result.errors.push({ key, message: `delete failed: ${err?.message ?? String(err)}` });
65761
+ }
65762
+ }
65763
+ }
65764
+ if (options.verify) {
65765
+ const destObjects = await toClient.listAllObjects({ bucket: options.to.bucket, prefix: options.to.prefix });
65766
+ const destBySizeKey = new Map(destObjects.map((o) => [o.Key, o.Size]));
65767
+ const missing = [];
65768
+ const sizeMismatches = [];
65769
+ let matched = 0;
65770
+ for (const { source, destKey } of toCopy) {
65771
+ if (!destBySizeKey.has(destKey)) {
65772
+ missing.push(destKey);
65773
+ continue;
65774
+ }
65775
+ const actual = destBySizeKey.get(destKey);
65776
+ if (actual !== source.Size) {
65777
+ sizeMismatches.push({ key: destKey, expected: source.Size, actual });
65778
+ continue;
65779
+ }
65780
+ matched++;
65781
+ }
65782
+ result.verification = {
65783
+ ok: missing.length === 0 && sizeMismatches.length === 0,
65784
+ matched,
65785
+ missing,
65786
+ sizeMismatches
65787
+ };
65788
+ }
65789
+ return result;
65790
+ }
65791
+
65792
+ // src/object-storage/index.ts
65573
65793
  var DEFAULT_REGION = {
65574
65794
  aws: "us-east-1",
65575
65795
  backblaze: "us-west-004",
@@ -82396,6 +82616,7 @@ export {
82396
82616
  resolveCaddyfile,
82397
82617
  requiresReplacement,
82398
82618
  replicaManager,
82619
+ remapKey,
82399
82620
  regionPairManager,
82400
82621
  quickHash,
82401
82622
  queueManagementManager,
@@ -82417,6 +82638,7 @@ export {
82417
82638
  multiRegionManager,
82418
82639
  multiAccountManager,
82419
82640
  migrationManager,
82641
+ migrateObjectStorage,
82420
82642
  metricsManager,
82421
82643
  mergeInfrastructure,
82422
82644
  makeAWSRequestOnce,
@@ -82430,6 +82652,7 @@ export {
82430
82652
  lambdaDestinationsManager,
82431
82653
  lambdaDLQManager,
82432
82654
  lambdaConcurrencyManager,
82655
+ keyMatchesFilters,
82433
82656
  isWebCryptoAvailable,
82434
82657
  isValidRegion,
82435
82658
  isOnDemandDomain,
@@ -23,6 +23,7 @@
23
23
  * ```
24
24
  */
25
25
  import { S3Client } from '../aws/s3';
26
+ export * from './migrate';
26
27
  export type ObjectStorageProvider = 'aws' | 'backblaze' | 'hetzner';
27
28
  export interface ObjectStorageCredentials {
28
29
  accessKeyId: string;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Cross-provider object-storage migration.
3
+ *
4
+ * Copies objects from one S3-compatible bucket to another — AWS S3, Backblaze
5
+ * B2, Hetzner Object Storage, in any direction. Both sides are driven by the
6
+ * same {@link createObjectStorageClient}, so the only thing that changes per
7
+ * side is the provider/region/endpoint/credentials.
8
+ *
9
+ * Bytes are copied (not strings) via {@link S3Client.getObjectBytes}, so binary
10
+ * payloads (images, archives, mail attachments) survive intact. Content-Type is
11
+ * preserved when the source reports it.
12
+ *
13
+ * The copy is idempotent: an object already present at the destination with the
14
+ * same size is skipped unless `force` is set. Keys may be remapped by stripping
15
+ * `fromPrefix` and prepending `toPrefix`, and filtered with include/exclude
16
+ * prefix lists so an operator can clearly see what was migrated vs. deliberately
17
+ * left behind.
18
+ */
19
+ import type { S3Client } from '../aws/s3';
20
+ import type { ObjectStorageProvider } from './index';
21
+ /** One side (source or destination) of a migration. */
22
+ export interface MigrateEndpoint {
23
+ provider: ObjectStorageProvider;
24
+ bucket: string;
25
+ region?: string;
26
+ /** Endpoint host override (no scheme). Defaults to the provider's standard endpoint. */
27
+ endpoint?: string;
28
+ forcePathStyle?: boolean;
29
+ /** Key prefix. On the source it scopes/strips; on the dest it is prepended. */
30
+ prefix?: string;
31
+ /** Explicit credentials. When omitted, resolved from the provider's env vars. */
32
+ credentials?: {
33
+ accessKeyId: string;
34
+ secretAccessKey: string;
35
+ sessionToken?: string;
36
+ };
37
+ /** Pre-built client (used by tests to inject an in-memory mock). */
38
+ client?: S3Client;
39
+ }
40
+ export interface MigrateOptions {
41
+ from: MigrateEndpoint;
42
+ to: MigrateEndpoint;
43
+ /** Only copy keys whose (source) key starts with one of these prefixes. */
44
+ include?: string[];
45
+ /** Skip keys whose (source) key starts with one of these prefixes. */
46
+ exclude?: string[];
47
+ /** Plan only — do not write to the destination. */
48
+ dryRun?: boolean;
49
+ /** Re-copy even if the destination already has an object of the same size. */
50
+ force?: boolean;
51
+ /** Delete destination keys (within the dest prefix) that are not in the copied set. Default OFF. */
52
+ deleteExtraneous?: boolean;
53
+ /** Max in-flight copies. Default 8. */
54
+ concurrency?: number;
55
+ /** After copying, re-list the destination and assert counts + sizes match the copied set. */
56
+ verify?: boolean;
57
+ /** Optional progress callback, invoked per object after it is copied/skipped. */
58
+ onProgress?: (event: MigrateProgress) => void;
59
+ }
60
+ export interface MigrateProgress {
61
+ /** Source key. */
62
+ key: string;
63
+ /** Destination key the object maps to. */
64
+ destKey: string;
65
+ size: number;
66
+ action: 'copied' | 'skipped' | 'excluded' | 'error' | 'planned';
67
+ /** 1-based index of this object within the full source listing. */
68
+ index: number;
69
+ total: number;
70
+ }
71
+ export interface MigrateError {
72
+ key: string;
73
+ message: string;
74
+ }
75
+ export interface MigratePlanItem {
76
+ key: string;
77
+ destKey: string;
78
+ size: number;
79
+ }
80
+ export interface MigrateResult {
81
+ copied: number;
82
+ skipped: number;
83
+ excluded: number;
84
+ bytesCopied: number;
85
+ errors: MigrateError[];
86
+ /** Keys that were excluded by include/exclude filters (source keys). */
87
+ excludedKeys: string[];
88
+ /** Keys deleted from the destination via `deleteExtraneous`. */
89
+ deleted: string[];
90
+ /** When `dryRun` is set, the objects that would be copied. */
91
+ plan?: MigratePlanItem[];
92
+ /** Verification outcome when `verify` is set. */
93
+ verification?: MigrateVerification;
94
+ }
95
+ export interface MigrateVerification {
96
+ ok: boolean;
97
+ /** Number of (key,size) pairs that matched at the destination. */
98
+ matched: number;
99
+ /** Copied keys missing at the destination. */
100
+ missing: string[];
101
+ /** Copied keys present at the destination but with a different size. */
102
+ sizeMismatches: Array<{
103
+ key: string;
104
+ expected: number;
105
+ actual: number;
106
+ }>;
107
+ }
108
+ /**
109
+ * Compute the destination key for a source key: strip the source prefix, then
110
+ * prepend the destination prefix. Exported for unit testing.
111
+ */
112
+ export declare function remapKey(sourceKey: string, fromPrefix?: string, toPrefix?: string): string;
113
+ /**
114
+ * Decide whether a source key passes the include/exclude prefix filters.
115
+ * `include` (when non-empty) is a whitelist; `exclude` always wins. Exported for
116
+ * unit testing.
117
+ */
118
+ export declare function keyMatchesFilters(key: string, include?: string[], exclude?: string[]): boolean;
119
+ /**
120
+ * Migrate objects from one S3-compatible bucket to another.
121
+ *
122
+ * @returns a structured {@link MigrateResult} so callers (CLI, buddy, scripts)
123
+ * can report exactly what was copied, skipped, excluded, deleted and verified.
124
+ */
125
+ export declare function migrateObjectStorage(options: MigrateOptions): Promise<MigrateResult>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ts-cloud",
3
3
  "type": "module",
4
- "version": "0.2.22",
4
+ "version": "0.2.23",
5
5
  "description": "A lightweight, performant infrastructure-as-code library and CLI for deploying both server-based (EC2) and serverless applications.",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -89,8 +89,8 @@
89
89
  "test": "bun test"
90
90
  },
91
91
  "dependencies": {
92
- "@ts-cloud/aws-types": "0.2.22",
93
- "@ts-cloud/core": "0.2.22",
92
+ "@ts-cloud/aws-types": "0.2.23",
93
+ "@ts-cloud/core": "0.2.23",
94
94
  "@stacksjs/ts-xml": "^0.1.0"
95
95
  },
96
96
  "devDependencies": {