@hot-updater/storage-s3 0.0.0-stage → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Sungyu Kang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ ADDITIONAL DISCLAIMER
26
+
27
+ The authors of this software shall not be held responsible for any legal issues,
28
+ regulatory violations, policy violations, or disputes arising from the use,
29
+ distribution, or integration of this software.
30
+
31
+ Users are solely responsible for ensuring compliance with all applicable laws,
32
+ regulations, and third-party policies (including but not limited to app store
33
+ policies).
package/dist/index.cjs ADDED
@@ -0,0 +1,167 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
3
+ let _aws_sdk_lib_storage = require("@aws-sdk/lib-storage");
4
+ let _aws_sdk_s3_request_presigner = require("@aws-sdk/s3-request-presigner");
5
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
6
+ //#region src/createS3Storage.ts
7
+ /** Devices download right after the update check that returns the URL. */
8
+ const DEFAULT_SIGNED_LIFETIME_SECONDS = 3600;
9
+ /** SigV4's limit on a presigned URL. */
10
+ const MAX_SIGNED_LIFETIME_SECONDS = 604800;
11
+ /** DeleteObjects takes at most 1000 keys a request. */
12
+ const DELETE_BATCH_SIZE = 1e3;
13
+ const isObjectNotFoundError = (error) => {
14
+ if (error instanceof Error && (error.name === "NotFound" || error.name === "NoSuchKey")) return true;
15
+ return typeof error === "object" && error !== null && "$metadata" in error && error.$metadata?.httpStatusCode === 404;
16
+ };
17
+ const trimSlashes = (value) => value.replace(/^\/+|\/+$/g, "");
18
+ /**
19
+ * A storage adapter over a bucket's S3 API, which AWS S3, Cloudflare R2, and
20
+ * Neon Object Storage all serve. Each provider package passes its own name,
21
+ * URI protocol, and client defaults.
22
+ */
23
+ const createS3Storage = ({ name, protocol }, config) => {
24
+ const { bucketName, basePath, getDownloadUrl, signedUrlExpiresIn = DEFAULT_SIGNED_LIFETIME_SECONDS, ...clientConfig } = config;
25
+ if (!Number.isFinite(signedUrlExpiresIn) || signedUrlExpiresIn <= 0 || signedUrlExpiresIn > MAX_SIGNED_LIFETIME_SECONDS) throw new Error(`${name} signedUrlExpiresIn must be between 1 and ${MAX_SIGNED_LIFETIME_SECONDS} seconds (seven days).`);
26
+ const client = new _aws_sdk_client_s3.S3Client(clientConfig);
27
+ const normalizedBasePath = trimSlashes(basePath ?? "");
28
+ const getStorageKey = (0, _hot_updater_plugin_core.createStorageKeyBuilder)(normalizedBasePath);
29
+ const getListPrefix = (prefix = "") => {
30
+ const value = [normalizedBasePath, trimSlashes(prefix)].filter(Boolean).join("/");
31
+ return value ? `${value}/` : "";
32
+ };
33
+ /**
34
+ * The URI `put` returns for the key, or null for a key no `put` writes,
35
+ * such as a folder marker that ends in "/".
36
+ */
37
+ const getListedStorageUri = (key) => {
38
+ try {
39
+ return (0, _hot_updater_plugin_core.createStorageUri)({
40
+ bucket: bucketName,
41
+ key,
42
+ protocol
43
+ });
44
+ } catch {
45
+ return null;
46
+ }
47
+ };
48
+ const getRelativeKey = (key) => {
49
+ if (!normalizedBasePath) return key;
50
+ const basePrefix = `${normalizedBasePath}/`;
51
+ return key.startsWith(basePrefix) ? key.slice(basePrefix.length) : key;
52
+ };
53
+ const parseAndValidate = (storageUri) => {
54
+ const parsed = (0, _hot_updater_plugin_core.parseStorageUri)(storageUri, protocol);
55
+ if (parsed.bucket !== bucketName) throw new Error(`Bucket name mismatch: expected "${bucketName}", but found "${parsed.bucket}".`);
56
+ return parsed;
57
+ };
58
+ return (0, _hot_updater_plugin_core.createStorageAdapter)({
59
+ name,
60
+ protocol,
61
+ async put({ key, body, contentLength, contentType }) {
62
+ const storageKey = getStorageKey(key);
63
+ await new _aws_sdk_lib_storage.Upload({
64
+ client,
65
+ params: {
66
+ Body: body,
67
+ Bucket: bucketName,
68
+ CacheControl: "max-age=31536000",
69
+ ...contentLength === void 0 ? {} : { ContentLength: contentLength },
70
+ ContentType: contentType,
71
+ Key: storageKey
72
+ }
73
+ }).done();
74
+ return { storageUri: (0, _hot_updater_plugin_core.createStorageUri)({
75
+ bucket: bucketName,
76
+ key: storageKey,
77
+ protocol
78
+ }) };
79
+ },
80
+ async get({ storageUri }) {
81
+ const { key } = parseAndValidate(storageUri);
82
+ try {
83
+ const response = await client.send(new _aws_sdk_client_s3.GetObjectCommand({
84
+ Bucket: bucketName,
85
+ Key: key
86
+ }));
87
+ if (!response.Body) return { response: null };
88
+ const headers = new Headers();
89
+ if (response.ContentType) headers.set("content-type", response.ContentType);
90
+ if (response.ContentLength !== void 0) headers.set("content-length", String(response.ContentLength));
91
+ return { response: new Response(response.Body.transformToWebStream(), { headers }) };
92
+ } catch (error) {
93
+ if (isObjectNotFoundError(error)) return { response: null };
94
+ throw error;
95
+ }
96
+ },
97
+ async getDownloadUrl({ storageUri }) {
98
+ const { key } = parseAndValidate(storageUri);
99
+ if (getDownloadUrl) return getDownloadUrl({ storageUri });
100
+ return { url: await (0, _aws_sdk_s3_request_presigner.getSignedUrl)(client, new _aws_sdk_client_s3.GetObjectCommand({
101
+ Bucket: bucketName,
102
+ Key: key
103
+ }), { expiresIn: signedUrlExpiresIn }) };
104
+ },
105
+ async exists({ storageUri }) {
106
+ const { key } = parseAndValidate(storageUri);
107
+ try {
108
+ await client.send(new _aws_sdk_client_s3.HeadObjectCommand({
109
+ Bucket: bucketName,
110
+ Key: key
111
+ }));
112
+ return { exists: true };
113
+ } catch (error) {
114
+ if (isObjectNotFoundError(error)) return { exists: false };
115
+ throw error;
116
+ }
117
+ },
118
+ async delete({ storageUri }) {
119
+ const { key } = parseAndValidate(storageUri);
120
+ await client.send(new _aws_sdk_client_s3.DeleteObjectCommand({
121
+ Bucket: bucketName,
122
+ Key: key
123
+ }));
124
+ return { deleted: true };
125
+ },
126
+ async listObjects(prefix) {
127
+ const objects = [];
128
+ let continuationToken;
129
+ do {
130
+ const response = await client.send(new _aws_sdk_client_s3.ListObjectsV2Command({
131
+ Bucket: bucketName,
132
+ ContinuationToken: continuationToken,
133
+ Prefix: getListPrefix(prefix)
134
+ }));
135
+ for (const object of response.Contents ?? []) {
136
+ if (!object.Key) continue;
137
+ const storageUri = getListedStorageUri(object.Key);
138
+ if (storageUri === null) continue;
139
+ objects.push({
140
+ key: getRelativeKey(object.Key),
141
+ lastModifiedAt: object.LastModified,
142
+ size: object.Size ?? 0,
143
+ storageUri
144
+ });
145
+ }
146
+ continuationToken = response.NextContinuationToken;
147
+ } while (continuationToken);
148
+ return objects;
149
+ },
150
+ async deleteObjects(relativeKeys) {
151
+ const keys = relativeKeys.map((key) => getStorageKey(key));
152
+ for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_SIZE) {
153
+ const batch = keys.slice(offset, offset + DELETE_BATCH_SIZE);
154
+ const response = await client.send(new _aws_sdk_client_s3.DeleteObjectsCommand({
155
+ Bucket: bucketName,
156
+ Delete: {
157
+ Objects: batch.map((Key) => ({ Key })),
158
+ Quiet: true
159
+ }
160
+ }));
161
+ if (response.Errors && response.Errors.length > 0) throw new Error(`Failed to delete ${response.Errors.length} S3 object(s) from bucket "${bucketName}".`);
162
+ }
163
+ }
164
+ });
165
+ };
166
+ //#endregion
167
+ exports.createS3Storage = createS3Storage;
@@ -0,0 +1,34 @@
1
+ import { S3ClientConfig } from "@aws-sdk/client-s3";
2
+ import { StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
3
+ //#region src/createS3Storage.d.ts
4
+ /** Who the adapter is: its name and the scheme of the URIs it writes. */
5
+ interface S3StorageIdentity {
6
+ /** The adapter's name, such as `s3Storage`. */
7
+ name: string;
8
+ /** The scheme of the storage URIs it writes, such as `s3`. */
9
+ protocol: string;
10
+ }
11
+ interface S3StorageConfig extends S3ClientConfig {
12
+ bucketName: string;
13
+ /** Base path where bundles will be stored in the bucket. */
14
+ basePath?: string;
15
+ /**
16
+ * The URL devices download an object from, such as a CDN's. Without it, a
17
+ * presigned URL that expires after `signedUrlExpiresIn`.
18
+ */
19
+ getDownloadUrl?: StorageAdapter["getDownloadUrl"];
20
+ /**
21
+ * Lifetime of the presigned download URLs in seconds, at most seven days,
22
+ * the SigV4 limit. @default 3600
23
+ */
24
+ signedUrlExpiresIn?: number;
25
+ }
26
+ type S3StorageOperations = "put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects";
27
+ /**
28
+ * A storage adapter over a bucket's S3 API, which AWS S3, Cloudflare R2, and
29
+ * Neon Object Storage all serve. Each provider package passes its own name,
30
+ * URI protocol, and client defaults.
31
+ */
32
+ declare const createS3Storage: ({ name, protocol }: S3StorageIdentity, config: S3StorageConfig) => StorageAdapterWith<S3StorageOperations>;
33
+ //#endregion
34
+ export { type S3ClientConfig, S3StorageConfig, S3StorageIdentity, S3StorageOperations, createS3Storage };
@@ -0,0 +1,34 @@
1
+ import { S3ClientConfig } from "@aws-sdk/client-s3";
2
+ import { StorageAdapter, StorageAdapterWith } from "@hot-updater/plugin-core";
3
+ //#region src/createS3Storage.d.ts
4
+ /** Who the adapter is: its name and the scheme of the URIs it writes. */
5
+ interface S3StorageIdentity {
6
+ /** The adapter's name, such as `s3Storage`. */
7
+ name: string;
8
+ /** The scheme of the storage URIs it writes, such as `s3`. */
9
+ protocol: string;
10
+ }
11
+ interface S3StorageConfig extends S3ClientConfig {
12
+ bucketName: string;
13
+ /** Base path where bundles will be stored in the bucket. */
14
+ basePath?: string;
15
+ /**
16
+ * The URL devices download an object from, such as a CDN's. Without it, a
17
+ * presigned URL that expires after `signedUrlExpiresIn`.
18
+ */
19
+ getDownloadUrl?: StorageAdapter["getDownloadUrl"];
20
+ /**
21
+ * Lifetime of the presigned download URLs in seconds, at most seven days,
22
+ * the SigV4 limit. @default 3600
23
+ */
24
+ signedUrlExpiresIn?: number;
25
+ }
26
+ type S3StorageOperations = "put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects";
27
+ /**
28
+ * A storage adapter over a bucket's S3 API, which AWS S3, Cloudflare R2, and
29
+ * Neon Object Storage all serve. Each provider package passes its own name,
30
+ * URI protocol, and client defaults.
31
+ */
32
+ declare const createS3Storage: ({ name, protocol }: S3StorageIdentity, config: S3StorageConfig) => StorageAdapterWith<S3StorageOperations>;
33
+ //#endregion
34
+ export { type S3ClientConfig, S3StorageConfig, S3StorageIdentity, S3StorageOperations, createS3Storage };
package/dist/index.mjs ADDED
@@ -0,0 +1,166 @@
1
+ import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
2
+ import { Upload } from "@aws-sdk/lib-storage";
3
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
4
+ import { createStorageAdapter, createStorageKeyBuilder, createStorageUri, parseStorageUri } from "@hot-updater/plugin-core";
5
+ //#region src/createS3Storage.ts
6
+ /** Devices download right after the update check that returns the URL. */
7
+ const DEFAULT_SIGNED_LIFETIME_SECONDS = 3600;
8
+ /** SigV4's limit on a presigned URL. */
9
+ const MAX_SIGNED_LIFETIME_SECONDS = 604800;
10
+ /** DeleteObjects takes at most 1000 keys a request. */
11
+ const DELETE_BATCH_SIZE = 1e3;
12
+ const isObjectNotFoundError = (error) => {
13
+ if (error instanceof Error && (error.name === "NotFound" || error.name === "NoSuchKey")) return true;
14
+ return typeof error === "object" && error !== null && "$metadata" in error && error.$metadata?.httpStatusCode === 404;
15
+ };
16
+ const trimSlashes = (value) => value.replace(/^\/+|\/+$/g, "");
17
+ /**
18
+ * A storage adapter over a bucket's S3 API, which AWS S3, Cloudflare R2, and
19
+ * Neon Object Storage all serve. Each provider package passes its own name,
20
+ * URI protocol, and client defaults.
21
+ */
22
+ const createS3Storage = ({ name, protocol }, config) => {
23
+ const { bucketName, basePath, getDownloadUrl, signedUrlExpiresIn = DEFAULT_SIGNED_LIFETIME_SECONDS, ...clientConfig } = config;
24
+ if (!Number.isFinite(signedUrlExpiresIn) || signedUrlExpiresIn <= 0 || signedUrlExpiresIn > MAX_SIGNED_LIFETIME_SECONDS) throw new Error(`${name} signedUrlExpiresIn must be between 1 and ${MAX_SIGNED_LIFETIME_SECONDS} seconds (seven days).`);
25
+ const client = new S3Client(clientConfig);
26
+ const normalizedBasePath = trimSlashes(basePath ?? "");
27
+ const getStorageKey = createStorageKeyBuilder(normalizedBasePath);
28
+ const getListPrefix = (prefix = "") => {
29
+ const value = [normalizedBasePath, trimSlashes(prefix)].filter(Boolean).join("/");
30
+ return value ? `${value}/` : "";
31
+ };
32
+ /**
33
+ * The URI `put` returns for the key, or null for a key no `put` writes,
34
+ * such as a folder marker that ends in "/".
35
+ */
36
+ const getListedStorageUri = (key) => {
37
+ try {
38
+ return createStorageUri({
39
+ bucket: bucketName,
40
+ key,
41
+ protocol
42
+ });
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+ const getRelativeKey = (key) => {
48
+ if (!normalizedBasePath) return key;
49
+ const basePrefix = `${normalizedBasePath}/`;
50
+ return key.startsWith(basePrefix) ? key.slice(basePrefix.length) : key;
51
+ };
52
+ const parseAndValidate = (storageUri) => {
53
+ const parsed = parseStorageUri(storageUri, protocol);
54
+ if (parsed.bucket !== bucketName) throw new Error(`Bucket name mismatch: expected "${bucketName}", but found "${parsed.bucket}".`);
55
+ return parsed;
56
+ };
57
+ return createStorageAdapter({
58
+ name,
59
+ protocol,
60
+ async put({ key, body, contentLength, contentType }) {
61
+ const storageKey = getStorageKey(key);
62
+ await new Upload({
63
+ client,
64
+ params: {
65
+ Body: body,
66
+ Bucket: bucketName,
67
+ CacheControl: "max-age=31536000",
68
+ ...contentLength === void 0 ? {} : { ContentLength: contentLength },
69
+ ContentType: contentType,
70
+ Key: storageKey
71
+ }
72
+ }).done();
73
+ return { storageUri: createStorageUri({
74
+ bucket: bucketName,
75
+ key: storageKey,
76
+ protocol
77
+ }) };
78
+ },
79
+ async get({ storageUri }) {
80
+ const { key } = parseAndValidate(storageUri);
81
+ try {
82
+ const response = await client.send(new GetObjectCommand({
83
+ Bucket: bucketName,
84
+ Key: key
85
+ }));
86
+ if (!response.Body) return { response: null };
87
+ const headers = new Headers();
88
+ if (response.ContentType) headers.set("content-type", response.ContentType);
89
+ if (response.ContentLength !== void 0) headers.set("content-length", String(response.ContentLength));
90
+ return { response: new Response(response.Body.transformToWebStream(), { headers }) };
91
+ } catch (error) {
92
+ if (isObjectNotFoundError(error)) return { response: null };
93
+ throw error;
94
+ }
95
+ },
96
+ async getDownloadUrl({ storageUri }) {
97
+ const { key } = parseAndValidate(storageUri);
98
+ if (getDownloadUrl) return getDownloadUrl({ storageUri });
99
+ return { url: await getSignedUrl(client, new GetObjectCommand({
100
+ Bucket: bucketName,
101
+ Key: key
102
+ }), { expiresIn: signedUrlExpiresIn }) };
103
+ },
104
+ async exists({ storageUri }) {
105
+ const { key } = parseAndValidate(storageUri);
106
+ try {
107
+ await client.send(new HeadObjectCommand({
108
+ Bucket: bucketName,
109
+ Key: key
110
+ }));
111
+ return { exists: true };
112
+ } catch (error) {
113
+ if (isObjectNotFoundError(error)) return { exists: false };
114
+ throw error;
115
+ }
116
+ },
117
+ async delete({ storageUri }) {
118
+ const { key } = parseAndValidate(storageUri);
119
+ await client.send(new DeleteObjectCommand({
120
+ Bucket: bucketName,
121
+ Key: key
122
+ }));
123
+ return { deleted: true };
124
+ },
125
+ async listObjects(prefix) {
126
+ const objects = [];
127
+ let continuationToken;
128
+ do {
129
+ const response = await client.send(new ListObjectsV2Command({
130
+ Bucket: bucketName,
131
+ ContinuationToken: continuationToken,
132
+ Prefix: getListPrefix(prefix)
133
+ }));
134
+ for (const object of response.Contents ?? []) {
135
+ if (!object.Key) continue;
136
+ const storageUri = getListedStorageUri(object.Key);
137
+ if (storageUri === null) continue;
138
+ objects.push({
139
+ key: getRelativeKey(object.Key),
140
+ lastModifiedAt: object.LastModified,
141
+ size: object.Size ?? 0,
142
+ storageUri
143
+ });
144
+ }
145
+ continuationToken = response.NextContinuationToken;
146
+ } while (continuationToken);
147
+ return objects;
148
+ },
149
+ async deleteObjects(relativeKeys) {
150
+ const keys = relativeKeys.map((key) => getStorageKey(key));
151
+ for (let offset = 0; offset < keys.length; offset += DELETE_BATCH_SIZE) {
152
+ const batch = keys.slice(offset, offset + DELETE_BATCH_SIZE);
153
+ const response = await client.send(new DeleteObjectsCommand({
154
+ Bucket: bucketName,
155
+ Delete: {
156
+ Objects: batch.map((Key) => ({ Key })),
157
+ Quiet: true
158
+ }
159
+ }));
160
+ if (response.Errors && response.Errors.length > 0) throw new Error(`Failed to delete ${response.Errors.length} S3 object(s) from bucket "${bucketName}".`);
161
+ }
162
+ }
163
+ });
164
+ };
165
+ //#endregion
166
+ export { createS3Storage };
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@hot-updater/storage-s3",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "1.1.5",
5
+ "description": "React Native OTA solution for self-hosted",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.mjs",
8
+ "types": "dist/index.d.cts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.cjs"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/gronxb/hot-updater"
20
+ },
21
+ "author": "gronxb <gron1gh1@gmail.com> (https://github.com/gronxb)",
22
+ "bugs": {
23
+ "url": "https://github.com/gronxb/hot-updater/issues"
24
+ },
25
+ "homepage": "https://github.com/gronxb/hot-updater#readme",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!**/*.spec.ts",
32
+ "package.json"
33
+ ],
34
+ "dependencies": {
35
+ "@aws-sdk/client-s3": "3.1066.0",
36
+ "@aws-sdk/lib-storage": "3.1066.0",
37
+ "@aws-sdk/s3-request-presigner": "3.1066.0",
38
+ "@hot-updater/plugin-core": "1.1.5"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^20",
42
+ "vitest": "4.1.4",
43
+ "@hot-updater/test-utils": "1.1.5"
44
+ },
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "test:type": "tsc --noEmit"
48
+ }
6
49
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.