@hot-updater/vercel 0.0.0-stage → 1.0.2

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.cjs ADDED
@@ -0,0 +1,208 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _hot_updater_plugin_core = require("@hot-updater/plugin-core");
3
+ let _vercel_blob = require("@vercel/blob");
4
+ //#region src/vercelBlobStorage.ts
5
+ const PROTOCOL = "vercel-blob";
6
+ const ONE_YEAR_IN_SECONDS = 31536e3;
7
+ /** Above this, `put` uploads in parts. */
8
+ const MULTIPART_THRESHOLD_BYTES = 104857600;
9
+ /** One delegation presigns every private download URL for a day. */
10
+ const DELEGATION_LIFETIME_MS = 864e5;
11
+ /** Vercel's limit on a delegation, and so on a presigned URL. */
12
+ const MAX_SIGNED_LIFETIME_SECONDS = 604800;
13
+ const DELETE_BATCH_SIZE = 100;
14
+ /**
15
+ * A character @vercel/blob drops from a pathname's URL, such as `#`, or
16
+ * reads as an escape, such as `%`, so `get` and presigned URLs miss the
17
+ * object; control characters; and the escape character `!`.
18
+ */
19
+ const ESCAPED_CHARACTER = /[#?%\\!\p{Cc}]/gu;
20
+ const ESCAPE_SEQUENCE = /!([0-9A-F]{2})/gu;
21
+ const utf8 = new TextEncoder();
22
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
23
+ /**
24
+ * The pathname Blob keeps a key under: each escaped character as `!` and
25
+ * the two hex digits of each of its UTF-8 bytes, such as `#` as `!23`.
26
+ * Distinct keys keep distinct pathnames, and deploy's keys, such as
27
+ * `<bundleId>/manifest.json`, are stored as they are.
28
+ */
29
+ const toPathname = (key) => key.replace(ESCAPED_CHARACTER, (character) => Array.from(utf8.encode(character), (byte) => `!${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
30
+ /** The key `toPathname` stored under `pathname`, or null for a pathname it never makes. */
31
+ const fromPathname = (pathname) => {
32
+ const bytes = [];
33
+ const flush = (parts) => {
34
+ if (bytes.length === 0) return true;
35
+ try {
36
+ parts.push(utf8Decoder.decode(new Uint8Array(bytes)));
37
+ } catch {
38
+ return false;
39
+ }
40
+ bytes.length = 0;
41
+ return true;
42
+ };
43
+ const parts = [];
44
+ let index = 0;
45
+ for (const match of pathname.matchAll(ESCAPE_SEQUENCE)) {
46
+ if (match.index !== index) {
47
+ if (!flush(parts)) return null;
48
+ parts.push(pathname.slice(index, match.index));
49
+ }
50
+ bytes.push(Number.parseInt(match[1], 16));
51
+ index = match.index + match[0].length;
52
+ }
53
+ if (!flush(parts)) return null;
54
+ parts.push(pathname.slice(index));
55
+ const key = parts.join("");
56
+ return toPathname(key) === pathname ? key : null;
57
+ };
58
+ /** The store ID a read-write token belongs to, as Vercel's CLI reads it. */
59
+ const storeIdOf = (token) => {
60
+ const [prefix, kind, rw, id] = token.split("_");
61
+ if (prefix !== "vercel" || kind !== "blob" || rw !== "rw" || !id) throw new Error("vercelBlobStorage needs the store's read-write token (vercel_blob_rw_...).");
62
+ return `store_${id}`;
63
+ };
64
+ /**
65
+ * Bundles in a Vercel Blob store. A public store's objects download from
66
+ * their Blob URL; a private store's from a presigned URL that expires.
67
+ */
68
+ const vercelBlobStorage = (config) => {
69
+ const { token, access } = config;
70
+ const storeId = storeIdOf(token);
71
+ const host = `${storeId.slice(6).toLowerCase()}.${access}.blob.vercel-storage.com`;
72
+ const normalizedBasePath = config.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
73
+ const getStorageKey = (0, _hot_updater_plugin_core.createStorageKeyBuilder)(normalizedBasePath);
74
+ const expiresIn = config.signedUrlExpiresIn ?? 3600;
75
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > MAX_SIGNED_LIFETIME_SECONDS) throw new Error(`vercelBlobStorage signedUrlExpiresIn must be between 1 and ${MAX_SIGNED_LIFETIME_SECONDS} seconds (seven days).`);
76
+ const expiresInMs = expiresIn * 1e3;
77
+ let delegation;
78
+ /** A read delegation that outlives `validUntil`, shared by every URL it presigns. */
79
+ const delegationUntil = async (validUntil) => {
80
+ const current = await delegation?.catch(() => void 0);
81
+ if (current !== void 0 && current.validUntil >= validUntil) return current;
82
+ const issued = (0, _vercel_blob.issueSignedToken)({
83
+ token,
84
+ operations: ["get"],
85
+ validUntil: Date.now() + Math.min(MAX_SIGNED_LIFETIME_SECONDS * 1e3, Math.max(DELEGATION_LIFETIME_MS, expiresInMs * 2))
86
+ });
87
+ delegation = issued;
88
+ return issued;
89
+ };
90
+ const parseAndValidate = (storageUri) => {
91
+ const parsed = (0, _hot_updater_plugin_core.parseStorageUri)(storageUri, PROTOCOL);
92
+ if (parsed.bucket !== storeId) throw new Error(`Blob store mismatch: expected "${storeId}", but found "${parsed.bucket}".`);
93
+ return parsed;
94
+ };
95
+ const getListPrefix = (prefix = "") => {
96
+ const value = [normalizedBasePath, prefix.replace(/^\/+|\/+$/g, "")].filter(Boolean).join("/");
97
+ return value ? `${toPathname(value)}/` : "";
98
+ };
99
+ const getRelativeKey = (key) => {
100
+ if (!normalizedBasePath) return key;
101
+ const basePrefix = `${normalizedBasePath}/`;
102
+ return key.startsWith(basePrefix) ? key.slice(basePrefix.length) : key;
103
+ };
104
+ return (0, _hot_updater_plugin_core.createStorageAdapter)({
105
+ name: "vercelBlobStorage",
106
+ protocol: PROTOCOL,
107
+ async put({ key, body, contentLength, contentType }) {
108
+ const storageKey = getStorageKey(key);
109
+ const storageUri = (0, _hot_updater_plugin_core.createStorageUri)({
110
+ protocol: PROTOCOL,
111
+ bucket: storeId,
112
+ key: storageKey
113
+ });
114
+ await (0, _vercel_blob.put)(toPathname(storageKey), body, {
115
+ access,
116
+ token,
117
+ addRandomSuffix: false,
118
+ allowOverwrite: true,
119
+ cacheControlMaxAge: ONE_YEAR_IN_SECONDS,
120
+ contentType,
121
+ multipart: contentLength === void 0 || contentLength > MULTIPART_THRESHOLD_BYTES
122
+ });
123
+ return { storageUri };
124
+ },
125
+ async get({ storageUri }) {
126
+ const { key } = parseAndValidate(storageUri);
127
+ const result = await (0, _vercel_blob.get)(toPathname(key), {
128
+ access,
129
+ token,
130
+ useCache: false
131
+ });
132
+ if (result?.statusCode !== 200) return { response: null };
133
+ const contentLength = result.headers.get("content-length");
134
+ return { response: new Response(result.stream, { headers: {
135
+ "content-type": result.blob.contentType,
136
+ ...contentLength && !result.headers.has("content-encoding") ? { "content-length": contentLength } : {}
137
+ } }) };
138
+ },
139
+ async getDownloadUrl({ storageUri }) {
140
+ const { key } = parseAndValidate(storageUri);
141
+ const pathname = toPathname(key);
142
+ if (access === "public") return { url: `https://${host}/${pathname.split("/").map(encodeURIComponent).join("/")}` };
143
+ const validUntil = Date.now() + expiresInMs;
144
+ const { presignedUrl } = await (0, _vercel_blob.presignUrl)(await delegationUntil(validUntil), {
145
+ access,
146
+ operation: "get",
147
+ pathname,
148
+ validUntil
149
+ });
150
+ return { url: presignedUrl };
151
+ },
152
+ async exists({ storageUri }) {
153
+ const { key } = parseAndValidate(storageUri);
154
+ try {
155
+ await (0, _vercel_blob.head)(toPathname(key), { token });
156
+ return { exists: true };
157
+ } catch (error) {
158
+ if (error instanceof _vercel_blob.BlobNotFoundError) return { exists: false };
159
+ throw error;
160
+ }
161
+ },
162
+ async delete({ storageUri }) {
163
+ const { key } = parseAndValidate(storageUri);
164
+ await (0, _vercel_blob.del)(toPathname(key), { token });
165
+ return { deleted: true };
166
+ },
167
+ async listObjects(prefix) {
168
+ const objects = [];
169
+ let cursor;
170
+ do {
171
+ const page = await (0, _vercel_blob.list)({
172
+ token,
173
+ prefix: getListPrefix(prefix),
174
+ limit: 1e3,
175
+ ...cursor === void 0 ? {} : { cursor }
176
+ });
177
+ for (const blob of page.blobs) {
178
+ const key = fromPathname(blob.pathname);
179
+ if (key === null) continue;
180
+ let storageUri;
181
+ try {
182
+ storageUri = (0, _hot_updater_plugin_core.createStorageUri)({
183
+ protocol: PROTOCOL,
184
+ bucket: storeId,
185
+ key
186
+ });
187
+ } catch {
188
+ continue;
189
+ }
190
+ objects.push({
191
+ key: getRelativeKey(key),
192
+ lastModifiedAt: blob.uploadedAt,
193
+ size: blob.size,
194
+ storageUri
195
+ });
196
+ }
197
+ cursor = page.hasMore ? page.cursor : void 0;
198
+ } while (cursor !== void 0);
199
+ return objects;
200
+ },
201
+ async deleteObjects(relativeKeys) {
202
+ const pathnames = relativeKeys.map((key) => toPathname(getStorageKey(key)));
203
+ for (let offset = 0; offset < pathnames.length; offset += DELETE_BATCH_SIZE) await (0, _vercel_blob.del)(pathnames.slice(offset, offset + DELETE_BATCH_SIZE), { token });
204
+ }
205
+ });
206
+ };
207
+ //#endregion
208
+ exports.vercelBlobStorage = vercelBlobStorage;
@@ -0,0 +1,26 @@
1
+ import { StorageAdapterWith } from "@hot-updater/plugin-core";
2
+ //#region src/vercelBlobStorage.d.ts
3
+ interface VercelBlobStorageConfig {
4
+ /**
5
+ * The Blob store's read-write token, `vercel_blob_rw_<storeId>_<secret>`:
6
+ * the `BLOB_READ_WRITE_TOKEN` Vercel adds to a project the store connects to.
7
+ */
8
+ token: string;
9
+ /** The store's access, which Vercel fixes when it creates the store. */
10
+ access: "public" | "private";
11
+ /** Base path where bundles will be stored in the store. */
12
+ basePath?: string;
13
+ /**
14
+ * Lifetime of a private store's download URLs in seconds, at most seven
15
+ * days, Vercel's limit. @default 3600
16
+ */
17
+ signedUrlExpiresIn?: number;
18
+ }
19
+ type VercelBlobStorageOperations = "put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects";
20
+ /**
21
+ * Bundles in a Vercel Blob store. A public store's objects download from
22
+ * their Blob URL; a private store's from a presigned URL that expires.
23
+ */
24
+ declare const vercelBlobStorage: (config: VercelBlobStorageConfig) => StorageAdapterWith<VercelBlobStorageOperations>;
25
+ //#endregion
26
+ export { VercelBlobStorageConfig, vercelBlobStorage };
@@ -0,0 +1,26 @@
1
+ import { StorageAdapterWith } from "@hot-updater/plugin-core";
2
+ //#region src/vercelBlobStorage.d.ts
3
+ interface VercelBlobStorageConfig {
4
+ /**
5
+ * The Blob store's read-write token, `vercel_blob_rw_<storeId>_<secret>`:
6
+ * the `BLOB_READ_WRITE_TOKEN` Vercel adds to a project the store connects to.
7
+ */
8
+ token: string;
9
+ /** The store's access, which Vercel fixes when it creates the store. */
10
+ access: "public" | "private";
11
+ /** Base path where bundles will be stored in the store. */
12
+ basePath?: string;
13
+ /**
14
+ * Lifetime of a private store's download URLs in seconds, at most seven
15
+ * days, Vercel's limit. @default 3600
16
+ */
17
+ signedUrlExpiresIn?: number;
18
+ }
19
+ type VercelBlobStorageOperations = "put" | "get" | "getDownloadUrl" | "exists" | "delete" | "listObjects" | "deleteObjects";
20
+ /**
21
+ * Bundles in a Vercel Blob store. A public store's objects download from
22
+ * their Blob URL; a private store's from a presigned URL that expires.
23
+ */
24
+ declare const vercelBlobStorage: (config: VercelBlobStorageConfig) => StorageAdapterWith<VercelBlobStorageOperations>;
25
+ //#endregion
26
+ export { VercelBlobStorageConfig, vercelBlobStorage };
package/dist/index.mjs ADDED
@@ -0,0 +1,207 @@
1
+ import { createStorageAdapter, createStorageKeyBuilder, createStorageUri, parseStorageUri } from "@hot-updater/plugin-core";
2
+ import { BlobNotFoundError, del, get, head, issueSignedToken, list, presignUrl, put } from "@vercel/blob";
3
+ //#region src/vercelBlobStorage.ts
4
+ const PROTOCOL = "vercel-blob";
5
+ const ONE_YEAR_IN_SECONDS = 31536e3;
6
+ /** Above this, `put` uploads in parts. */
7
+ const MULTIPART_THRESHOLD_BYTES = 104857600;
8
+ /** One delegation presigns every private download URL for a day. */
9
+ const DELEGATION_LIFETIME_MS = 864e5;
10
+ /** Vercel's limit on a delegation, and so on a presigned URL. */
11
+ const MAX_SIGNED_LIFETIME_SECONDS = 604800;
12
+ const DELETE_BATCH_SIZE = 100;
13
+ /**
14
+ * A character @vercel/blob drops from a pathname's URL, such as `#`, or
15
+ * reads as an escape, such as `%`, so `get` and presigned URLs miss the
16
+ * object; control characters; and the escape character `!`.
17
+ */
18
+ const ESCAPED_CHARACTER = /[#?%\\!\p{Cc}]/gu;
19
+ const ESCAPE_SEQUENCE = /!([0-9A-F]{2})/gu;
20
+ const utf8 = new TextEncoder();
21
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
22
+ /**
23
+ * The pathname Blob keeps a key under: each escaped character as `!` and
24
+ * the two hex digits of each of its UTF-8 bytes, such as `#` as `!23`.
25
+ * Distinct keys keep distinct pathnames, and deploy's keys, such as
26
+ * `<bundleId>/manifest.json`, are stored as they are.
27
+ */
28
+ const toPathname = (key) => key.replace(ESCAPED_CHARACTER, (character) => Array.from(utf8.encode(character), (byte) => `!${byte.toString(16).toUpperCase().padStart(2, "0")}`).join(""));
29
+ /** The key `toPathname` stored under `pathname`, or null for a pathname it never makes. */
30
+ const fromPathname = (pathname) => {
31
+ const bytes = [];
32
+ const flush = (parts) => {
33
+ if (bytes.length === 0) return true;
34
+ try {
35
+ parts.push(utf8Decoder.decode(new Uint8Array(bytes)));
36
+ } catch {
37
+ return false;
38
+ }
39
+ bytes.length = 0;
40
+ return true;
41
+ };
42
+ const parts = [];
43
+ let index = 0;
44
+ for (const match of pathname.matchAll(ESCAPE_SEQUENCE)) {
45
+ if (match.index !== index) {
46
+ if (!flush(parts)) return null;
47
+ parts.push(pathname.slice(index, match.index));
48
+ }
49
+ bytes.push(Number.parseInt(match[1], 16));
50
+ index = match.index + match[0].length;
51
+ }
52
+ if (!flush(parts)) return null;
53
+ parts.push(pathname.slice(index));
54
+ const key = parts.join("");
55
+ return toPathname(key) === pathname ? key : null;
56
+ };
57
+ /** The store ID a read-write token belongs to, as Vercel's CLI reads it. */
58
+ const storeIdOf = (token) => {
59
+ const [prefix, kind, rw, id] = token.split("_");
60
+ if (prefix !== "vercel" || kind !== "blob" || rw !== "rw" || !id) throw new Error("vercelBlobStorage needs the store's read-write token (vercel_blob_rw_...).");
61
+ return `store_${id}`;
62
+ };
63
+ /**
64
+ * Bundles in a Vercel Blob store. A public store's objects download from
65
+ * their Blob URL; a private store's from a presigned URL that expires.
66
+ */
67
+ const vercelBlobStorage = (config) => {
68
+ const { token, access } = config;
69
+ const storeId = storeIdOf(token);
70
+ const host = `${storeId.slice(6).toLowerCase()}.${access}.blob.vercel-storage.com`;
71
+ const normalizedBasePath = config.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
72
+ const getStorageKey = createStorageKeyBuilder(normalizedBasePath);
73
+ const expiresIn = config.signedUrlExpiresIn ?? 3600;
74
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > MAX_SIGNED_LIFETIME_SECONDS) throw new Error(`vercelBlobStorage signedUrlExpiresIn must be between 1 and ${MAX_SIGNED_LIFETIME_SECONDS} seconds (seven days).`);
75
+ const expiresInMs = expiresIn * 1e3;
76
+ let delegation;
77
+ /** A read delegation that outlives `validUntil`, shared by every URL it presigns. */
78
+ const delegationUntil = async (validUntil) => {
79
+ const current = await delegation?.catch(() => void 0);
80
+ if (current !== void 0 && current.validUntil >= validUntil) return current;
81
+ const issued = issueSignedToken({
82
+ token,
83
+ operations: ["get"],
84
+ validUntil: Date.now() + Math.min(MAX_SIGNED_LIFETIME_SECONDS * 1e3, Math.max(DELEGATION_LIFETIME_MS, expiresInMs * 2))
85
+ });
86
+ delegation = issued;
87
+ return issued;
88
+ };
89
+ const parseAndValidate = (storageUri) => {
90
+ const parsed = parseStorageUri(storageUri, PROTOCOL);
91
+ if (parsed.bucket !== storeId) throw new Error(`Blob store mismatch: expected "${storeId}", but found "${parsed.bucket}".`);
92
+ return parsed;
93
+ };
94
+ const getListPrefix = (prefix = "") => {
95
+ const value = [normalizedBasePath, prefix.replace(/^\/+|\/+$/g, "")].filter(Boolean).join("/");
96
+ return value ? `${toPathname(value)}/` : "";
97
+ };
98
+ const getRelativeKey = (key) => {
99
+ if (!normalizedBasePath) return key;
100
+ const basePrefix = `${normalizedBasePath}/`;
101
+ return key.startsWith(basePrefix) ? key.slice(basePrefix.length) : key;
102
+ };
103
+ return createStorageAdapter({
104
+ name: "vercelBlobStorage",
105
+ protocol: PROTOCOL,
106
+ async put({ key, body, contentLength, contentType }) {
107
+ const storageKey = getStorageKey(key);
108
+ const storageUri = createStorageUri({
109
+ protocol: PROTOCOL,
110
+ bucket: storeId,
111
+ key: storageKey
112
+ });
113
+ await put(toPathname(storageKey), body, {
114
+ access,
115
+ token,
116
+ addRandomSuffix: false,
117
+ allowOverwrite: true,
118
+ cacheControlMaxAge: ONE_YEAR_IN_SECONDS,
119
+ contentType,
120
+ multipart: contentLength === void 0 || contentLength > MULTIPART_THRESHOLD_BYTES
121
+ });
122
+ return { storageUri };
123
+ },
124
+ async get({ storageUri }) {
125
+ const { key } = parseAndValidate(storageUri);
126
+ const result = await get(toPathname(key), {
127
+ access,
128
+ token,
129
+ useCache: false
130
+ });
131
+ if (result?.statusCode !== 200) return { response: null };
132
+ const contentLength = result.headers.get("content-length");
133
+ return { response: new Response(result.stream, { headers: {
134
+ "content-type": result.blob.contentType,
135
+ ...contentLength && !result.headers.has("content-encoding") ? { "content-length": contentLength } : {}
136
+ } }) };
137
+ },
138
+ async getDownloadUrl({ storageUri }) {
139
+ const { key } = parseAndValidate(storageUri);
140
+ const pathname = toPathname(key);
141
+ if (access === "public") return { url: `https://${host}/${pathname.split("/").map(encodeURIComponent).join("/")}` };
142
+ const validUntil = Date.now() + expiresInMs;
143
+ const { presignedUrl } = await presignUrl(await delegationUntil(validUntil), {
144
+ access,
145
+ operation: "get",
146
+ pathname,
147
+ validUntil
148
+ });
149
+ return { url: presignedUrl };
150
+ },
151
+ async exists({ storageUri }) {
152
+ const { key } = parseAndValidate(storageUri);
153
+ try {
154
+ await head(toPathname(key), { token });
155
+ return { exists: true };
156
+ } catch (error) {
157
+ if (error instanceof BlobNotFoundError) return { exists: false };
158
+ throw error;
159
+ }
160
+ },
161
+ async delete({ storageUri }) {
162
+ const { key } = parseAndValidate(storageUri);
163
+ await del(toPathname(key), { token });
164
+ return { deleted: true };
165
+ },
166
+ async listObjects(prefix) {
167
+ const objects = [];
168
+ let cursor;
169
+ do {
170
+ const page = await list({
171
+ token,
172
+ prefix: getListPrefix(prefix),
173
+ limit: 1e3,
174
+ ...cursor === void 0 ? {} : { cursor }
175
+ });
176
+ for (const blob of page.blobs) {
177
+ const key = fromPathname(blob.pathname);
178
+ if (key === null) continue;
179
+ let storageUri;
180
+ try {
181
+ storageUri = createStorageUri({
182
+ protocol: PROTOCOL,
183
+ bucket: storeId,
184
+ key
185
+ });
186
+ } catch {
187
+ continue;
188
+ }
189
+ objects.push({
190
+ key: getRelativeKey(key),
191
+ lastModifiedAt: blob.uploadedAt,
192
+ size: blob.size,
193
+ storageUri
194
+ });
195
+ }
196
+ cursor = page.hasMore ? page.cursor : void 0;
197
+ } while (cursor !== void 0);
198
+ return objects;
199
+ },
200
+ async deleteObjects(relativeKeys) {
201
+ const pathnames = relativeKeys.map((key) => toPathname(getStorageKey(key)));
202
+ for (let offset = 0; offset < pathnames.length; offset += DELETE_BATCH_SIZE) await del(pathnames.slice(offset, offset + DELETE_BATCH_SIZE), { token });
203
+ }
204
+ });
205
+ };
206
+ //#endregion
207
+ export { vercelBlobStorage };