@hammr/cdn 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +604 -0
- package/dist/index.cjs +419 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +357 -0
- package/dist/index.d.ts +357 -0
- package/dist/index.js +388 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// ../normalizer/dist/index.js
|
|
2
|
+
async function sha256(input) {
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
const data = encoder.encode(input);
|
|
5
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
6
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
7
|
+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
8
|
+
return hashHex;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/types.ts
|
|
12
|
+
var CONTENT_TYPES = {
|
|
13
|
+
// Images
|
|
14
|
+
"jpg": "image/jpeg",
|
|
15
|
+
"jpeg": "image/jpeg",
|
|
16
|
+
"png": "image/png",
|
|
17
|
+
"gif": "image/gif",
|
|
18
|
+
"webp": "image/webp",
|
|
19
|
+
"svg": "image/svg+xml",
|
|
20
|
+
"ico": "image/x-icon",
|
|
21
|
+
// Documents
|
|
22
|
+
"pdf": "application/pdf",
|
|
23
|
+
"json": "application/json",
|
|
24
|
+
"xml": "application/xml",
|
|
25
|
+
"txt": "text/plain",
|
|
26
|
+
"html": "text/html",
|
|
27
|
+
"css": "text/css",
|
|
28
|
+
"csv": "text/csv",
|
|
29
|
+
// Scripts
|
|
30
|
+
"js": "application/javascript",
|
|
31
|
+
"mjs": "application/javascript",
|
|
32
|
+
"ts": "application/typescript",
|
|
33
|
+
"wasm": "application/wasm",
|
|
34
|
+
// Archives
|
|
35
|
+
"zip": "application/zip",
|
|
36
|
+
"gz": "application/gzip",
|
|
37
|
+
"tar": "application/x-tar",
|
|
38
|
+
// Media
|
|
39
|
+
"mp3": "audio/mpeg",
|
|
40
|
+
"mp4": "video/mp4",
|
|
41
|
+
"webm": "video/webm",
|
|
42
|
+
"ogg": "audio/ogg",
|
|
43
|
+
// Fonts
|
|
44
|
+
"woff": "font/woff",
|
|
45
|
+
"woff2": "font/woff2",
|
|
46
|
+
"ttf": "font/ttf",
|
|
47
|
+
"otf": "font/otf"
|
|
48
|
+
};
|
|
49
|
+
function detectContentType(filename, defaultType = "application/octet-stream") {
|
|
50
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
51
|
+
return ext ? CONTENT_TYPES[ext] || defaultType : defaultType;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/CDN.ts
|
|
55
|
+
var CDN = class {
|
|
56
|
+
constructor(options) {
|
|
57
|
+
this.storage = options.storage;
|
|
58
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
59
|
+
this.cacheMaxAge = options.cacheMaxAge ?? 31536e3;
|
|
60
|
+
this.defaultContentType = options.defaultContentType ?? "application/octet-stream";
|
|
61
|
+
this.cors = options.cors ?? true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Upload an artifact and get content-addressable URL
|
|
65
|
+
*
|
|
66
|
+
* @param content - Raw bytes to upload
|
|
67
|
+
* @param metadata - Optional metadata (filename, contentType, etc.)
|
|
68
|
+
* @returns Upload result with hash and URL
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* const result = await cdn.put(imageBytes, {
|
|
73
|
+
* filename: 'logo.png',
|
|
74
|
+
* contentType: 'image/png'
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* console.log(result.url); // https://cdn.example.com/a/5e88489...
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
async put(content, metadata = {}) {
|
|
81
|
+
const bytes = content instanceof ArrayBuffer ? new Uint8Array(content) : content;
|
|
82
|
+
const hash = await sha256(Array.from(bytes).map((b) => String.fromCharCode(b)).join(""));
|
|
83
|
+
const exists = await this.storage.exists(hash);
|
|
84
|
+
const contentType = metadata.contentType || (metadata.filename ? detectContentType(metadata.filename, this.defaultContentType) : this.defaultContentType);
|
|
85
|
+
const fullMetadata = {
|
|
86
|
+
contentType,
|
|
87
|
+
filename: metadata.filename,
|
|
88
|
+
size: bytes.length,
|
|
89
|
+
uploadedAt: Date.now(),
|
|
90
|
+
customMetadata: metadata.customMetadata
|
|
91
|
+
};
|
|
92
|
+
await this.storage.put(hash, bytes, fullMetadata);
|
|
93
|
+
const ext = metadata.filename?.split(".").pop();
|
|
94
|
+
const urlPath = ext ? `${hash}.${ext}` : hash;
|
|
95
|
+
return {
|
|
96
|
+
hash,
|
|
97
|
+
url: `${this.baseUrl}/a/${urlPath}`,
|
|
98
|
+
created: !exists,
|
|
99
|
+
metadata: fullMetadata
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Retrieve an artifact by hash
|
|
104
|
+
*
|
|
105
|
+
* @param hash - Content-addressable hash
|
|
106
|
+
* @returns Artifact or null if not found
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```typescript
|
|
110
|
+
* const artifact = await cdn.get('5e884898...');
|
|
111
|
+
* if (artifact) {
|
|
112
|
+
* console.log(artifact.metadata.contentType);
|
|
113
|
+
* }
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
async get(hash) {
|
|
117
|
+
return this.storage.get(hash);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Delete an artifact by hash
|
|
121
|
+
*
|
|
122
|
+
* @param hash - Content-addressable hash
|
|
123
|
+
* @returns true if deleted, false if not found
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```typescript
|
|
127
|
+
* const deleted = await cdn.delete('5e884898...');
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
async delete(hash) {
|
|
131
|
+
return this.storage.delete(hash);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Check if artifact exists
|
|
135
|
+
*
|
|
136
|
+
* @param hash - Content-addressable hash
|
|
137
|
+
* @returns true if exists
|
|
138
|
+
*/
|
|
139
|
+
async exists(hash) {
|
|
140
|
+
return this.storage.exists(hash);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* List all artifacts (if supported by storage adapter)
|
|
144
|
+
*
|
|
145
|
+
* @param options - List options
|
|
146
|
+
* @returns Array of hashes
|
|
147
|
+
*/
|
|
148
|
+
async list(options) {
|
|
149
|
+
if (!this.storage.list) {
|
|
150
|
+
throw new Error("Storage adapter does not support list()");
|
|
151
|
+
}
|
|
152
|
+
return this.storage.list(options);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Handle HTTP request (for Cloudflare Workers, Express, etc.)
|
|
156
|
+
*
|
|
157
|
+
* Routes:
|
|
158
|
+
* - PUT /artifact - Upload artifact
|
|
159
|
+
* - GET /a/:hash(.ext) - Retrieve artifact
|
|
160
|
+
* - DELETE /a/:hash - Delete artifact
|
|
161
|
+
*
|
|
162
|
+
* @param request - HTTP request
|
|
163
|
+
* @returns HTTP response
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* export default {
|
|
168
|
+
* async fetch(request, env) {
|
|
169
|
+
* const cdn = new CDN({
|
|
170
|
+
* storage: new R2Storage(env.ARTIFACTS),
|
|
171
|
+
* baseUrl: 'https://cdn.example.com'
|
|
172
|
+
* });
|
|
173
|
+
* return cdn.handleRequest(request);
|
|
174
|
+
* }
|
|
175
|
+
* }
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
async handleRequest(request) {
|
|
179
|
+
const url = new URL(request.url);
|
|
180
|
+
const method = request.method;
|
|
181
|
+
if (method === "PUT" && url.pathname === "/artifact") {
|
|
182
|
+
try {
|
|
183
|
+
const bytes = await request.arrayBuffer();
|
|
184
|
+
const filename = url.searchParams.get("filename") || request.headers.get("x-filename") || void 0;
|
|
185
|
+
const contentType = request.headers.get("content-type") || void 0;
|
|
186
|
+
const result = await this.put(bytes, { filename, contentType });
|
|
187
|
+
return new Response(JSON.stringify(result, null, 2), {
|
|
188
|
+
status: 200,
|
|
189
|
+
headers: this.getCORSHeaders({
|
|
190
|
+
"Content-Type": "application/json"
|
|
191
|
+
})
|
|
192
|
+
});
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return new Response(
|
|
195
|
+
JSON.stringify({ error: error instanceof Error ? error.message : "Upload failed" }),
|
|
196
|
+
{
|
|
197
|
+
status: 500,
|
|
198
|
+
headers: this.getCORSHeaders({ "Content-Type": "application/json" })
|
|
199
|
+
}
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (method === "GET" && url.pathname.startsWith("/a/")) {
|
|
204
|
+
const filename = url.pathname.replace("/a/", "");
|
|
205
|
+
const hash = filename.split(".")[0];
|
|
206
|
+
try {
|
|
207
|
+
const artifact = await this.get(hash);
|
|
208
|
+
if (!artifact) {
|
|
209
|
+
return new Response("Not found", {
|
|
210
|
+
status: 404,
|
|
211
|
+
headers: this.getCORSHeaders()
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const contentType = artifact.metadata.contentType || this.defaultContentType;
|
|
215
|
+
return new Response(artifact.body, {
|
|
216
|
+
status: 200,
|
|
217
|
+
headers: this.getCORSHeaders({
|
|
218
|
+
"Content-Type": contentType,
|
|
219
|
+
"Cache-Control": `public, max-age=${this.cacheMaxAge}, immutable`,
|
|
220
|
+
"ETag": `"${hash}"`
|
|
221
|
+
})
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return new Response("Internal server error", {
|
|
225
|
+
status: 500,
|
|
226
|
+
headers: this.getCORSHeaders()
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (method === "DELETE" && url.pathname.startsWith("/a/")) {
|
|
231
|
+
const filename = url.pathname.replace("/a/", "");
|
|
232
|
+
const hash = filename.split(".")[0];
|
|
233
|
+
try {
|
|
234
|
+
const deleted = await this.delete(hash);
|
|
235
|
+
if (!deleted) {
|
|
236
|
+
return new Response("Not found", {
|
|
237
|
+
status: 404,
|
|
238
|
+
headers: this.getCORSHeaders()
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return new Response(JSON.stringify({ deleted: true, hash }), {
|
|
242
|
+
status: 200,
|
|
243
|
+
headers: this.getCORSHeaders({ "Content-Type": "application/json" })
|
|
244
|
+
});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return new Response("Internal server error", {
|
|
247
|
+
status: 500,
|
|
248
|
+
headers: this.getCORSHeaders()
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (method === "OPTIONS") {
|
|
253
|
+
return new Response(null, {
|
|
254
|
+
status: 204,
|
|
255
|
+
headers: this.getCORSHeaders()
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return new Response("Not found", {
|
|
259
|
+
status: 404,
|
|
260
|
+
headers: this.getCORSHeaders()
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Get CORS headers if enabled
|
|
265
|
+
*/
|
|
266
|
+
getCORSHeaders(additionalHeaders = {}) {
|
|
267
|
+
if (!this.cors) return additionalHeaders;
|
|
268
|
+
return {
|
|
269
|
+
"Access-Control-Allow-Origin": "*",
|
|
270
|
+
"Access-Control-Allow-Methods": "GET, PUT, DELETE, OPTIONS",
|
|
271
|
+
"Access-Control-Allow-Headers": "Content-Type, X-Filename",
|
|
272
|
+
"Access-Control-Max-Age": "86400",
|
|
273
|
+
...additionalHeaders
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// src/adapters/MemoryStorage.ts
|
|
279
|
+
var MemoryStorage = class {
|
|
280
|
+
constructor() {
|
|
281
|
+
this.storage = /* @__PURE__ */ new Map();
|
|
282
|
+
}
|
|
283
|
+
async put(hash, content, metadata = {}) {
|
|
284
|
+
const bytes = content instanceof ArrayBuffer ? new Uint8Array(content) : content;
|
|
285
|
+
this.storage.set(hash, {
|
|
286
|
+
content: bytes,
|
|
287
|
+
metadata: {
|
|
288
|
+
size: bytes.length,
|
|
289
|
+
uploadedAt: Date.now(),
|
|
290
|
+
...metadata
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
async get(hash) {
|
|
295
|
+
const stored = this.storage.get(hash);
|
|
296
|
+
if (!stored) return null;
|
|
297
|
+
return {
|
|
298
|
+
hash,
|
|
299
|
+
body: stored.content.buffer,
|
|
300
|
+
metadata: stored.metadata
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
async delete(hash) {
|
|
304
|
+
return this.storage.delete(hash);
|
|
305
|
+
}
|
|
306
|
+
async exists(hash) {
|
|
307
|
+
return this.storage.has(hash);
|
|
308
|
+
}
|
|
309
|
+
async list(options = {}) {
|
|
310
|
+
const hashes = Array.from(this.storage.keys());
|
|
311
|
+
const limit = options.limit ?? 1e3;
|
|
312
|
+
return hashes.slice(0, limit);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Clear all stored artifacts (useful for testing)
|
|
316
|
+
*/
|
|
317
|
+
clear() {
|
|
318
|
+
this.storage.clear();
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Get storage size (number of artifacts)
|
|
322
|
+
*/
|
|
323
|
+
size() {
|
|
324
|
+
return this.storage.size;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// src/adapters/R2Storage.ts
|
|
329
|
+
var R2Storage = class {
|
|
330
|
+
constructor(bucket) {
|
|
331
|
+
this.bucket = bucket;
|
|
332
|
+
}
|
|
333
|
+
async put(hash, content, metadata = {}) {
|
|
334
|
+
await this.bucket.put(hash, content, {
|
|
335
|
+
httpMetadata: {
|
|
336
|
+
contentType: metadata.contentType
|
|
337
|
+
},
|
|
338
|
+
customMetadata: {
|
|
339
|
+
filename: metadata.filename || "",
|
|
340
|
+
uploadedAt: String(metadata.uploadedAt || Date.now()),
|
|
341
|
+
...metadata.customMetadata
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
async get(hash) {
|
|
346
|
+
const obj = await this.bucket.get(hash);
|
|
347
|
+
if (!obj || !obj.body) return null;
|
|
348
|
+
return {
|
|
349
|
+
hash,
|
|
350
|
+
body: obj.body,
|
|
351
|
+
metadata: {
|
|
352
|
+
contentType: obj.httpMetadata?.contentType,
|
|
353
|
+
filename: obj.customMetadata?.filename,
|
|
354
|
+
size: obj.size,
|
|
355
|
+
uploadedAt: obj.customMetadata?.uploadedAt ? Number(obj.customMetadata.uploadedAt) : void 0,
|
|
356
|
+
customMetadata: obj.customMetadata
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
async delete(hash) {
|
|
361
|
+
const exists = await this.exists(hash);
|
|
362
|
+
if (!exists) return false;
|
|
363
|
+
await this.bucket.delete(hash);
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
async exists(hash) {
|
|
367
|
+
const obj = await this.bucket.head(hash);
|
|
368
|
+
return obj !== null;
|
|
369
|
+
}
|
|
370
|
+
async list(options = {}) {
|
|
371
|
+
if (!this.bucket.list) {
|
|
372
|
+
throw new Error("R2 list() not available in this environment");
|
|
373
|
+
}
|
|
374
|
+
const result = await this.bucket.list({
|
|
375
|
+
limit: options.limit ?? 1e3,
|
|
376
|
+
cursor: options.cursor
|
|
377
|
+
});
|
|
378
|
+
return result.objects.map((obj) => obj.key);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
export {
|
|
382
|
+
CDN,
|
|
383
|
+
CONTENT_TYPES,
|
|
384
|
+
MemoryStorage,
|
|
385
|
+
R2Storage,
|
|
386
|
+
detectContentType
|
|
387
|
+
};
|
|
388
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../normalizer/src/hash.ts","../../normalizer/src/Normalizer.ts","../src/types.ts","../src/CDN.ts","../src/adapters/MemoryStorage.ts","../src/adapters/R2Storage.ts"],"sourcesContent":["/**\n * SHA256 Hashing Utilities\n * \n * Uses Web Crypto API (available in modern browsers and Node.js 18+)\n * Works in both browser and Cloudflare Workers environments.\n * \n * All PII MUST be normalized before hashing to ensure consistent results.\n */\n\nimport type { Sha256Hash } from './types';\n\n/**\n * SHA256 hash a string using Web Crypto API\n * \n * Returns 64 lowercase hex characters.\n * Works in both browser and Node.js 18+ (Web Crypto API).\n * \n * @param input - String to hash (should be normalized first)\n * @returns Promise resolving to 64-character lowercase hex string\n * \n * @example\n * ```typescript\n * const hash = await sha256('hello@example.com');\n * // Returns: \"5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8\"\n * ```\n */\nexport async function sha256(input: string): Promise<Sha256Hash> {\n const encoder = new TextEncoder();\n const data = encoder.encode(input);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');\n return hashHex as Sha256Hash;\n}\n\n/**\n * Validate that a string is a valid SHA256 hash\n * \n * Checks for 64 lowercase hexadecimal characters.\n * \n * @param value - String to validate\n * @returns true if valid SHA256 hash format\n * \n * @example\n * ```typescript\n * isSha256('abc123'); // false\n * isSha256('5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8'); // true\n * ```\n */\nexport function isSha256(value: string): value is Sha256Hash {\n return /^[a-f0-9]{64}$/.test(value);\n}\n","/**\n * Normalizer Class\n * \n * Normalizes and hashes PII fields for ad platform APIs (Meta CAPI, Google Ads, etc.)\n * \n * Normalization rules ensure consistent hashing:\n * - Email: lowercase, trim\n * - Phone: E.164 format (+15551234567)\n * - Name: lowercase, trim, single spaces\n * - Gender: single char (m/f)\n * - Date of Birth: YYYYMMDD format\n * - Address: lowercase, trim, format-specific rules\n * \n * Meta CAPI Hashing Requirements:\n * - HASH: email, phone, first_name, last_name, gender, date_of_birth, city, state, zip_code, country, external_id\n * - DO NOT HASH: ip_address, user_agent, fbc, fbp, subscription_id, lead_id\n */\n\nimport type { NormalizerOptions, RawUserData, NormalizedUserData, Sha256Hash } from './types';\nimport { sha256 } from './hash';\n\nexport class Normalizer {\n private options: Required<NormalizerOptions>;\n\n constructor(options: NormalizerOptions = {}) {\n this.options = {\n defaultCountryCode: options.defaultCountryCode ?? '1',\n hashAddressFields: options.hashAddressFields ?? false,\n };\n }\n\n /* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n * NORMALIZATION METHODS\n * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */\n\n /**\n * Normalize email: lowercase, trim whitespace\n * Returns null if invalid format\n */\n normalizeEmail(email: string): string | null {\n if (!email || typeof email !== 'string') return null;\n const normalized = email.toLowerCase().trim();\n if (!normalized.includes('@') || normalized.length < 5) return null;\n return normalized;\n }\n\n /**\n * Normalize phone to E.164 format (+15551234567)\n * Returns null if invalid\n */\n normalizePhone(phone: string, countryCode?: string): string | null {\n if (!phone || typeof phone !== 'string') return null;\n const code = countryCode ?? this.options.defaultCountryCode;\n let digits = phone.replace(/[^\\d+]/g, '');\n if (digits.startsWith('+')) digits = digits.slice(1);\n if (digits.length < 10) return null;\n if (digits.length === 10) digits = code + digits;\n return '+' + digits;\n }\n\n /**\n * Normalize name: lowercase, trim, remove extra spaces\n * Returns null if empty\n */\n normalizeName(name: string): string | null {\n if (!name || typeof name !== 'string') return null;\n const normalized = name.toLowerCase().trim().replace(/\\s+/g, ' ');\n if (normalized.length === 0) return null;\n return normalized;\n }\n\n /**\n * Normalize gender: single lowercase char (m/f)\n * Accepts: m, male, f, female\n * Returns null if invalid\n */\n normalizeGender(gender: string): string | null {\n if (!gender || typeof gender !== 'string') return null;\n const g = gender.toLowerCase().trim();\n if (g === 'm' || g === 'male') return 'm';\n if (g === 'f' || g === 'female') return 'f';\n return null;\n }\n\n /**\n * Normalize date of birth: YYYYMMDD format\n * Accepts formats: YYYY-MM-DD, YYYY/MM/DD, YYYY.MM.DD, YYYYMMDD\n * Returns null if invalid\n */\n normalizeDateOfBirth(dob: string): string | null {\n if (!dob || typeof dob !== 'string') return null;\n const cleaned = dob.replace(/[-\\/\\.]/g, '');\n if (!/^\\d{8}$/.test(cleaned)) return null;\n return cleaned;\n }\n\n /**\n * Normalize city: lowercase, trim\n */\n normalizeCity(city: string): string | null {\n if (!city || typeof city !== 'string') return null;\n const normalized = city.toLowerCase().trim();\n if (normalized.length === 0) return null;\n return normalized;\n }\n\n /**\n * Normalize state/region: lowercase, trim\n */\n normalizeState(state: string): string | null {\n if (!state || typeof state !== 'string') return null;\n const normalized = state.toLowerCase().trim();\n if (normalized.length === 0) return null;\n return normalized;\n }\n\n /**\n * Normalize zip/postal code: lowercase, no spaces\n */\n normalizeZipCode(zip: string): string | null {\n if (!zip || typeof zip !== 'string') return null;\n const normalized = zip.toLowerCase().trim().replace(/\\s+/g, '');\n if (normalized.length === 0) return null;\n return normalized;\n }\n\n /**\n * Normalize country: 2-letter code, lowercase\n * Returns null if not exactly 2 characters\n */\n normalizeCountry(country: string): string | null {\n if (!country || typeof country !== 'string') return null;\n const normalized = country.toLowerCase().trim();\n if (normalized.length !== 2) return null;\n return normalized;\n }\n\n /* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n * HASH METHODS\n * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */\n\n /**\n * Hash email (normalize then hash)\n */\n async hashEmail(email: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeEmail(email);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash phone (normalize then hash)\n */\n async hashPhone(phone: string, countryCode?: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizePhone(phone, countryCode);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash name (normalize then hash)\n */\n async hashName(name: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeName(name);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash gender (normalize then hash)\n */\n async hashGender(gender: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeGender(gender);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash date of birth (normalize then hash)\n */\n async hashDateOfBirth(dob: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeDateOfBirth(dob);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash city (normalize then hash)\n */\n async hashCity(city: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeCity(city);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash state (normalize then hash)\n */\n async hashState(state: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeState(state);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash zip code (normalize then hash)\n */\n async hashZipCode(zip: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeZipCode(zip);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash country (normalize then hash)\n */\n async hashCountry(country: string): Promise<Sha256Hash | null> {\n const normalized = this.normalizeCountry(country);\n return normalized ? sha256(normalized) : null;\n }\n\n /**\n * Hash external ID (trim only, no other normalization)\n */\n async hashExternalId(externalId: string): Promise<Sha256Hash | null> {\n if (!externalId || typeof externalId !== 'string') return null;\n return sha256(externalId.trim());\n }\n\n /* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n * BATCH NORMALIZATION\n * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */\n\n /**\n * Normalize and hash all user data\n * \n * Raw PII in, hashed data out.\n * \n * @param raw - Raw user data (unhashed PII)\n * @returns Normalized and hashed user data\n * \n * @example\n * ```typescript\n * const normalizer = new Normalizer();\n * const normalized = await normalizer.normalize({\n * email: 'JOHN@EXAMPLE.COM',\n * phone: '555-123-4567',\n * first_name: 'John',\n * last_name: 'Doe',\n * city: 'San Francisco',\n * state: 'CA',\n * country: 'US',\n * });\n * \n * // Result:\n * // {\n * // email: '5e884898...', (hashed)\n * // phone: 'a1b2c3d4...', (hashed)\n * // first_name: 'e5f6g7h8...', (hashed)\n * // last_name: 'i9j0k1l2...', (hashed)\n * // city: 'san francisco', (normalized, not hashed by default)\n * // state: 'ca', (normalized, not hashed by default)\n * // country: 'us', (normalized, not hashed by default)\n * // }\n * ```\n */\n async normalize(raw: RawUserData): Promise<NormalizedUserData> {\n const result: NormalizedUserData = {};\n\n // Hash contact PII\n if (raw.email) {\n const h = await this.hashEmail(raw.email);\n if (h) result.email = h;\n }\n if (raw.phone) {\n const h = await this.hashPhone(raw.phone);\n if (h) result.phone = h;\n }\n if (raw.first_name) {\n const h = await this.hashName(raw.first_name);\n if (h) result.first_name = h;\n }\n if (raw.last_name) {\n const h = await this.hashName(raw.last_name);\n if (h) result.last_name = h;\n }\n if (raw.gender) {\n const h = await this.hashGender(raw.gender);\n if (h) result.gender = h;\n }\n if (raw.date_of_birth) {\n const h = await this.hashDateOfBirth(raw.date_of_birth);\n if (h) result.date_of_birth = h;\n }\n if (raw.external_id) {\n const h = await this.hashExternalId(raw.external_id);\n if (h) result.external_id = h;\n }\n\n // Address fields - hash only if configured to do so\n if (raw.city) {\n if (this.options.hashAddressFields) {\n const h = await this.hashCity(raw.city);\n if (h) result.city = h as unknown as string;\n } else {\n result.city = raw.city;\n }\n }\n if (raw.state) {\n if (this.options.hashAddressFields) {\n const h = await this.hashState(raw.state);\n if (h) result.state = h as unknown as string;\n } else {\n result.state = raw.state;\n }\n }\n if (raw.zip_code) {\n if (this.options.hashAddressFields) {\n const h = await this.hashZipCode(raw.zip_code);\n if (h) result.zip_code = h as unknown as string;\n } else {\n result.zip_code = raw.zip_code;\n }\n }\n if (raw.country) {\n if (this.options.hashAddressFields) {\n const h = await this.hashCountry(raw.country);\n if (h) result.country = h as unknown as string;\n } else {\n result.country = raw.country;\n }\n }\n\n // Pass through unhashed fields\n if (raw.ip_address) result.ip_address = raw.ip_address;\n if (raw.user_agent) result.user_agent = raw.user_agent;\n if (raw.subscription_id) result.subscription_id = raw.subscription_id;\n if (raw.lead_id) result.lead_id = raw.lead_id;\n if (raw.anonymous_id) result.anonymous_id = raw.anonymous_id;\n if (raw.traits) result.traits = raw.traits;\n\n return result;\n }\n}\n","/**\n * Type definitions for CDN storage system\n */\n\n/**\n * Metadata for stored artifacts\n */\nexport interface ArtifactMetadata {\n /**\n * Content-Type header (e.g., 'image/png', 'application/json')\n */\n contentType?: string;\n\n /**\n * Original filename (if provided)\n */\n filename?: string;\n\n /**\n * File size in bytes\n */\n size?: number;\n\n /**\n * Upload timestamp (Unix milliseconds)\n */\n uploadedAt?: number;\n\n /**\n * Custom metadata fields\n */\n customMetadata?: Record<string, string>;\n}\n\n/**\n * Stored artifact with content and metadata\n */\nexport interface StoredArtifact {\n /**\n * Content-addressable hash (SHA256)\n */\n hash: string;\n\n /**\n * Artifact content (raw bytes)\n */\n body: ArrayBuffer | ReadableStream | Uint8Array;\n\n /**\n * Metadata about the artifact\n */\n metadata: ArtifactMetadata;\n}\n\n/**\n * Result from uploading an artifact\n */\nexport interface UploadResult {\n /**\n * Content-addressable hash\n */\n hash: string;\n\n /**\n * Public URL to access the artifact\n */\n url: string;\n\n /**\n * Whether this was a new upload (true) or already existed (false)\n */\n created: boolean;\n\n /**\n * Artifact metadata\n */\n metadata: ArtifactMetadata;\n}\n\n/**\n * Storage adapter interface\n * \n * Implement this to support different storage backends (R2, S3, etc.)\n */\nexport interface StorageAdapter {\n /**\n * Store artifact content with hash as key\n * \n * @param hash - Content-addressable hash (SHA256)\n * @param content - Raw bytes to store\n * @param metadata - Optional metadata\n */\n put(hash: string, content: ArrayBuffer | Uint8Array, metadata?: ArtifactMetadata): Promise<void>;\n\n /**\n * Retrieve artifact by hash\n * \n * @param hash - Content-addressable hash\n * @returns Artifact or null if not found\n */\n get(hash: string): Promise<StoredArtifact | null>;\n\n /**\n * Delete artifact by hash\n * \n * @param hash - Content-addressable hash\n * @returns true if deleted, false if not found\n */\n delete(hash: string): Promise<boolean>;\n\n /**\n * Check if artifact exists\n * \n * @param hash - Content-addressable hash\n * @returns true if exists, false otherwise\n */\n exists(hash: string): Promise<boolean>;\n\n /**\n * List all artifact hashes (optional, for admin/debugging)\n * \n * @param options - List options (limit, cursor, etc.)\n * @returns Array of hashes\n */\n list?(options?: { limit?: number; cursor?: string }): Promise<string[]>;\n}\n\n/**\n * Options for CDN\n */\nexport interface CDNOptions {\n /**\n * Storage adapter (R2, S3, Memory, etc.)\n */\n storage: StorageAdapter;\n\n /**\n * Base URL for generating artifact URLs\n * @example 'https://cdn.example.com'\n */\n baseUrl: string;\n\n /**\n * Cache-Control max-age in seconds\n * @default 31536000 (1 year)\n */\n cacheMaxAge?: number;\n\n /**\n * Default content type if not detected\n * @default 'application/octet-stream'\n */\n defaultContentType?: string;\n\n /**\n * Enable CORS headers\n * @default true\n */\n cors?: boolean;\n}\n\n/**\n * Content type map for common extensions\n */\nexport const CONTENT_TYPES: Record<string, string> = {\n // Images\n 'jpg': 'image/jpeg',\n 'jpeg': 'image/jpeg',\n 'png': 'image/png',\n 'gif': 'image/gif',\n 'webp': 'image/webp',\n 'svg': 'image/svg+xml',\n 'ico': 'image/x-icon',\n\n // Documents\n 'pdf': 'application/pdf',\n 'json': 'application/json',\n 'xml': 'application/xml',\n 'txt': 'text/plain',\n 'html': 'text/html',\n 'css': 'text/css',\n 'csv': 'text/csv',\n\n // Scripts\n 'js': 'application/javascript',\n 'mjs': 'application/javascript',\n 'ts': 'application/typescript',\n 'wasm': 'application/wasm',\n\n // Archives\n 'zip': 'application/zip',\n 'gz': 'application/gzip',\n 'tar': 'application/x-tar',\n\n // Media\n 'mp3': 'audio/mpeg',\n 'mp4': 'video/mp4',\n 'webm': 'video/webm',\n 'ogg': 'audio/ogg',\n\n // Fonts\n 'woff': 'font/woff',\n 'woff2': 'font/woff2',\n 'ttf': 'font/ttf',\n 'otf': 'font/otf',\n};\n\n/**\n * Detect content type from filename extension\n */\nexport function detectContentType(filename: string, defaultType = 'application/octet-stream'): string {\n const ext = filename.split('.').pop()?.toLowerCase();\n return ext ? (CONTENT_TYPES[ext] || defaultType) : defaultType;\n}\n","/**\n * CDN - Content-Addressable Storage System\n * \n * Features:\n * - Content-addressable (SHA256 hashing via @sygnl/normalizer)\n * - Storage abstraction (R2, S3, Memory, etc.)\n * - Automatic content-type detection\n * - Immutable artifacts (hash-based URLs)\n * - Cache-friendly headers\n */\n\nimport { sha256 } from '@sygnl/normalizer';\nimport type {\n CDNOptions,\n StorageAdapter,\n UploadResult,\n StoredArtifact,\n ArtifactMetadata,\n} from './types';\nimport { detectContentType } from './types';\n\nexport class CDN {\n private storage: StorageAdapter;\n private baseUrl: string;\n private cacheMaxAge: number;\n private defaultContentType: string;\n private cors: boolean;\n\n constructor(options: CDNOptions) {\n this.storage = options.storage;\n this.baseUrl = options.baseUrl.replace(/\\/$/, ''); // Remove trailing slash\n this.cacheMaxAge = options.cacheMaxAge ?? 31536000; // 1 year default\n this.defaultContentType = options.defaultContentType ?? 'application/octet-stream';\n this.cors = options.cors ?? true;\n }\n\n /**\n * Upload an artifact and get content-addressable URL\n * \n * @param content - Raw bytes to upload\n * @param metadata - Optional metadata (filename, contentType, etc.)\n * @returns Upload result with hash and URL\n * \n * @example\n * ```typescript\n * const result = await cdn.put(imageBytes, {\n * filename: 'logo.png',\n * contentType: 'image/png'\n * });\n * \n * console.log(result.url); // https://cdn.example.com/a/5e88489...\n * ```\n */\n async put(\n content: ArrayBuffer | Uint8Array,\n metadata: Partial<ArtifactMetadata> = {}\n ): Promise<UploadResult> {\n // Convert to Uint8Array for consistent hashing\n const bytes = content instanceof ArrayBuffer ? new Uint8Array(content) : content;\n\n // Hash the content (content-addressable)\n const hash = await sha256(Array.from(bytes).map(b => String.fromCharCode(b)).join(''));\n\n // Check if already exists (idempotent uploads)\n const exists = await this.storage.exists(hash);\n\n // Detect content type from filename if not provided\n const contentType = metadata.contentType ||\n (metadata.filename ? detectContentType(metadata.filename, this.defaultContentType) : this.defaultContentType);\n\n const fullMetadata: ArtifactMetadata = {\n contentType,\n filename: metadata.filename,\n size: bytes.length,\n uploadedAt: Date.now(),\n customMetadata: metadata.customMetadata,\n };\n\n // Store artifact\n await this.storage.put(hash, bytes, fullMetadata);\n\n // Generate URL with optional filename extension\n const ext = metadata.filename?.split('.').pop();\n const urlPath = ext ? `${hash}.${ext}` : hash;\n\n return {\n hash,\n url: `${this.baseUrl}/a/${urlPath}`,\n created: !exists,\n metadata: fullMetadata,\n };\n }\n\n /**\n * Retrieve an artifact by hash\n * \n * @param hash - Content-addressable hash\n * @returns Artifact or null if not found\n * \n * @example\n * ```typescript\n * const artifact = await cdn.get('5e884898...');\n * if (artifact) {\n * console.log(artifact.metadata.contentType);\n * }\n * ```\n */\n async get(hash: string): Promise<StoredArtifact | null> {\n return this.storage.get(hash);\n }\n\n /**\n * Delete an artifact by hash\n * \n * @param hash - Content-addressable hash\n * @returns true if deleted, false if not found\n * \n * @example\n * ```typescript\n * const deleted = await cdn.delete('5e884898...');\n * ```\n */\n async delete(hash: string): Promise<boolean> {\n return this.storage.delete(hash);\n }\n\n /**\n * Check if artifact exists\n * \n * @param hash - Content-addressable hash\n * @returns true if exists\n */\n async exists(hash: string): Promise<boolean> {\n return this.storage.exists(hash);\n }\n\n /**\n * List all artifacts (if supported by storage adapter)\n * \n * @param options - List options\n * @returns Array of hashes\n */\n async list(options?: { limit?: number; cursor?: string }): Promise<string[]> {\n if (!this.storage.list) {\n throw new Error('Storage adapter does not support list()');\n }\n return this.storage.list(options);\n }\n\n /**\n * Handle HTTP request (for Cloudflare Workers, Express, etc.)\n * \n * Routes:\n * - PUT /artifact - Upload artifact\n * - GET /a/:hash(.ext) - Retrieve artifact\n * - DELETE /a/:hash - Delete artifact\n * \n * @param request - HTTP request\n * @returns HTTP response\n * \n * @example\n * ```typescript\n * export default {\n * async fetch(request, env) {\n * const cdn = new CDN({\n * storage: new R2Storage(env.ARTIFACTS),\n * baseUrl: 'https://cdn.example.com'\n * });\n * return cdn.handleRequest(request);\n * }\n * }\n * ```\n */\n async handleRequest(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const method = request.method;\n\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n // PUT /artifact - Upload artifact\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n if (method === 'PUT' && url.pathname === '/artifact') {\n try {\n const bytes = await request.arrayBuffer();\n \n // Extract filename from query param or header\n const filename = url.searchParams.get('filename') ||\n request.headers.get('x-filename') ||\n undefined;\n\n const contentType = request.headers.get('content-type') || undefined;\n\n const result = await this.put(bytes, { filename, contentType });\n\n return new Response(JSON.stringify(result, null, 2), {\n status: 200,\n headers: this.getCORSHeaders({\n 'Content-Type': 'application/json',\n }),\n });\n } catch (error) {\n return new Response(\n JSON.stringify({ error: error instanceof Error ? error.message : 'Upload failed' }),\n {\n status: 500,\n headers: this.getCORSHeaders({ 'Content-Type': 'application/json' }),\n }\n );\n }\n }\n\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n // GET /a/:hash(.ext) - Retrieve artifact\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n if (method === 'GET' && url.pathname.startsWith('/a/')) {\n const filename = url.pathname.replace('/a/', '');\n const hash = filename.split('.')[0]; // Remove extension if present\n\n try {\n const artifact = await this.get(hash);\n \n if (!artifact) {\n return new Response('Not found', {\n status: 404,\n headers: this.getCORSHeaders(),\n });\n }\n\n const contentType = artifact.metadata.contentType || this.defaultContentType;\n\n return new Response(artifact.body, {\n status: 200,\n headers: this.getCORSHeaders({\n 'Content-Type': contentType,\n 'Cache-Control': `public, max-age=${this.cacheMaxAge}, immutable`,\n 'ETag': `\"${hash}\"`,\n }),\n });\n } catch (error) {\n return new Response('Internal server error', {\n status: 500,\n headers: this.getCORSHeaders(),\n });\n }\n }\n\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n // DELETE /a/:hash - Delete artifact\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n if (method === 'DELETE' && url.pathname.startsWith('/a/')) {\n const filename = url.pathname.replace('/a/', '');\n const hash = filename.split('.')[0];\n\n try {\n const deleted = await this.delete(hash);\n\n if (!deleted) {\n return new Response('Not found', {\n status: 404,\n headers: this.getCORSHeaders(),\n });\n }\n\n return new Response(JSON.stringify({ deleted: true, hash }), {\n status: 200,\n headers: this.getCORSHeaders({ 'Content-Type': 'application/json' }),\n });\n } catch (error) {\n return new Response('Internal server error', {\n status: 500,\n headers: this.getCORSHeaders(),\n });\n }\n }\n\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n // OPTIONS - CORS preflight\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n if (method === 'OPTIONS') {\n return new Response(null, {\n status: 204,\n headers: this.getCORSHeaders(),\n });\n }\n\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n // 404 - Not found\n // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n return new Response('Not found', {\n status: 404,\n headers: this.getCORSHeaders(),\n });\n }\n\n /**\n * Get CORS headers if enabled\n */\n private getCORSHeaders(additionalHeaders: Record<string, string> = {}): Record<string, string> {\n if (!this.cors) return additionalHeaders;\n\n return {\n 'Access-Control-Allow-Origin': '*',\n 'Access-Control-Allow-Methods': 'GET, PUT, DELETE, OPTIONS',\n 'Access-Control-Allow-Headers': 'Content-Type, X-Filename',\n 'Access-Control-Max-Age': '86400',\n ...additionalHeaders,\n };\n }\n}\n","/**\n * In-memory storage adapter\n * \n * Useful for:\n * - Testing\n * - Development\n * - Temporary caching\n * \n * NOT for production (data lost on restart)\n */\n\nimport type { StorageAdapter, StoredArtifact, ArtifactMetadata } from '../types';\n\nexport class MemoryStorage implements StorageAdapter {\n private storage: Map<string, { content: Uint8Array; metadata: ArtifactMetadata }> = new Map();\n\n async put(hash: string, content: ArrayBuffer | Uint8Array, metadata: ArtifactMetadata = {}): Promise<void> {\n const bytes = content instanceof ArrayBuffer ? new Uint8Array(content) : content;\n this.storage.set(hash, {\n content: bytes,\n metadata: {\n size: bytes.length,\n uploadedAt: Date.now(),\n ...metadata,\n },\n });\n }\n\n async get(hash: string): Promise<StoredArtifact | null> {\n const stored = this.storage.get(hash);\n if (!stored) return null;\n\n return {\n hash,\n body: stored.content.buffer as ArrayBuffer,\n metadata: stored.metadata,\n };\n }\n\n async delete(hash: string): Promise<boolean> {\n return this.storage.delete(hash);\n }\n\n async exists(hash: string): Promise<boolean> {\n return this.storage.has(hash);\n }\n\n async list(options: { limit?: number; cursor?: string } = {}): Promise<string[]> {\n const hashes = Array.from(this.storage.keys());\n const limit = options.limit ?? 1000;\n return hashes.slice(0, limit);\n }\n\n /**\n * Clear all stored artifacts (useful for testing)\n */\n clear(): void {\n this.storage.clear();\n }\n\n /**\n * Get storage size (number of artifacts)\n */\n size(): number {\n return this.storage.size;\n }\n}\n","/**\n * Cloudflare R2 storage adapter\n * \n * Uses Cloudflare R2 for artifact storage.\n * \n * Usage:\n * ```typescript\n * const storage = new R2Storage(env.ARTIFACTS);\n * ```\n */\n\nimport type { StorageAdapter, StoredArtifact, ArtifactMetadata } from '../types';\n\n/**\n * Minimal R2 bucket interface\n * (matches Cloudflare Workers R2Bucket type)\n */\nexport interface R2Bucket {\n put(key: string, value: ArrayBuffer | Uint8Array | ReadableStream, options?: {\n httpMetadata?: {\n contentType?: string;\n };\n customMetadata?: Record<string, string>;\n }): Promise<any>;\n \n get(key: string): Promise<{\n body?: ReadableStream | ArrayBuffer;\n httpMetadata?: {\n contentType?: string;\n };\n customMetadata?: Record<string, string>;\n size?: number;\n uploaded?: Date;\n } | null>;\n \n delete(key: string): Promise<void>;\n \n head(key: string): Promise<{\n httpMetadata?: {\n contentType?: string;\n };\n customMetadata?: Record<string, string>;\n size?: number;\n uploaded?: Date;\n } | null>;\n \n list?(options?: { limit?: number; cursor?: string }): Promise<{\n objects: Array<{ key: string }>;\n truncated: boolean;\n cursor?: string;\n }>;\n}\n\nexport class R2Storage implements StorageAdapter {\n constructor(private bucket: R2Bucket) {}\n\n async put(hash: string, content: ArrayBuffer | Uint8Array, metadata: ArtifactMetadata = {}): Promise<void> {\n await this.bucket.put(hash, content, {\n httpMetadata: {\n contentType: metadata.contentType,\n },\n customMetadata: {\n filename: metadata.filename || '',\n uploadedAt: String(metadata.uploadedAt || Date.now()),\n ...metadata.customMetadata,\n },\n });\n }\n\n async get(hash: string): Promise<StoredArtifact | null> {\n const obj = await this.bucket.get(hash);\n if (!obj || !obj.body) return null;\n\n return {\n hash,\n body: obj.body,\n metadata: {\n contentType: obj.httpMetadata?.contentType,\n filename: obj.customMetadata?.filename,\n size: obj.size,\n uploadedAt: obj.customMetadata?.uploadedAt ? Number(obj.customMetadata.uploadedAt) : undefined,\n customMetadata: obj.customMetadata,\n },\n };\n }\n\n async delete(hash: string): Promise<boolean> {\n const exists = await this.exists(hash);\n if (!exists) return false;\n await this.bucket.delete(hash);\n return true;\n }\n\n async exists(hash: string): Promise<boolean> {\n const obj = await this.bucket.head(hash);\n return obj !== null;\n }\n\n async list(options: { limit?: number; cursor?: string } = {}): Promise<string[]> {\n if (!this.bucket.list) {\n throw new Error('R2 list() not available in this environment');\n }\n\n const result = await this.bucket.list({\n limit: options.limit ?? 1000,\n cursor: options.cursor,\n });\n\n return result.objects.map((obj) => obj.key);\n }\n}\n"],"mappings":";AA0BA,eAAsB,OAAO,OAAoC;AAC/D,QAAM,UAAU,IAAI,YAAY;AAChC,QAAM,OAAO,QAAQ,OAAO,KAAK;AACjC,QAAM,aAAa,MAAM,OAAO,OAAO,OAAO,WAAW,IAAI;AAC7D,QAAM,YAAY,MAAM,KAAK,IAAI,WAAW,UAAU,CAAC;AACvD,QAAM,UAAU,UAAU,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAAE,KAAK,EAAE;AAC7E,SAAO;AACT;;;AEmIO,IAAM,gBAAwC;AAAA;AAAA,EAEnD,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA;AAAA,EAGP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,OAAO;AAAA;AAAA,EAGP,MAAM;AAAA,EACN,OAAO;AAAA,EACP,MAAM;AAAA,EACN,QAAQ;AAAA;AAAA,EAGR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,OAAO;AAAA;AAAA,EAGP,OAAO;AAAA,EACP,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,OAAO;AAAA;AAAA,EAGP,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,OAAO;AAAA,EACP,OAAO;AACT;AAKO,SAAS,kBAAkB,UAAkB,cAAc,4BAAoC;AACpG,QAAM,MAAM,SAAS,MAAM,GAAG,EAAE,IAAI,GAAG,YAAY;AACnD,SAAO,MAAO,cAAc,GAAG,KAAK,cAAe;AACrD;;;AChMO,IAAM,MAAN,MAAU;AAAA,EAOf,YAAY,SAAqB;AAC/B,SAAK,UAAU,QAAQ;AACvB,SAAK,UAAU,QAAQ,QAAQ,QAAQ,OAAO,EAAE;AAChD,SAAK,cAAc,QAAQ,eAAe;AAC1C,SAAK,qBAAqB,QAAQ,sBAAsB;AACxD,SAAK,OAAO,QAAQ,QAAQ;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmBA,MAAM,IACJ,SACA,WAAsC,CAAC,GAChB;AAEvB,UAAM,QAAQ,mBAAmB,cAAc,IAAI,WAAW,OAAO,IAAI;AAGzE,UAAM,OAAO,MAAM,OAAO,MAAM,KAAK,KAAK,EAAE,IAAI,OAAK,OAAO,aAAa,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;AAGrF,UAAM,SAAS,MAAM,KAAK,QAAQ,OAAO,IAAI;AAG7C,UAAM,cAAc,SAAS,gBAC1B,SAAS,WAAW,kBAAkB,SAAS,UAAU,KAAK,kBAAkB,IAAI,KAAK;AAE5F,UAAM,eAAiC;AAAA,MACrC;AAAA,MACA,UAAU,SAAS;AAAA,MACnB,MAAM,MAAM;AAAA,MACZ,YAAY,KAAK,IAAI;AAAA,MACrB,gBAAgB,SAAS;AAAA,IAC3B;AAGA,UAAM,KAAK,QAAQ,IAAI,MAAM,OAAO,YAAY;AAGhD,UAAM,MAAM,SAAS,UAAU,MAAM,GAAG,EAAE,IAAI;AAC9C,UAAM,UAAU,MAAM,GAAG,IAAI,IAAI,GAAG,KAAK;AAEzC,WAAO;AAAA,MACL;AAAA,MACA,KAAK,GAAG,KAAK,OAAO,MAAM,OAAO;AAAA,MACjC,SAAS,CAAC;AAAA,MACV,UAAU;AAAA,IACZ;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAgBA,MAAM,IAAI,MAA8C;AACtD,WAAO,KAAK,QAAQ,IAAI,IAAI;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAaA,MAAM,OAAO,MAAgC;AAC3C,WAAO,KAAK,QAAQ,OAAO,IAAI;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,OAAO,MAAgC;AAC3C,WAAO,KAAK,QAAQ,OAAO,IAAI;AAAA,EACjC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,KAAK,SAAkE;AAC3E,QAAI,CAAC,KAAK,QAAQ,MAAM;AACtB,YAAM,IAAI,MAAM,yCAAyC;AAAA,IAC3D;AACA,WAAO,KAAK,QAAQ,KAAK,OAAO;AAAA,EAClC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA0BA,MAAM,cAAc,SAAqC;AACvD,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,SAAS,QAAQ;AAKvB,QAAI,WAAW,SAAS,IAAI,aAAa,aAAa;AACpD,UAAI;AACF,cAAM,QAAQ,MAAM,QAAQ,YAAY;AAGxC,cAAM,WAAW,IAAI,aAAa,IAAI,UAAU,KAC9C,QAAQ,QAAQ,IAAI,YAAY,KAChC;AAEF,cAAM,cAAc,QAAQ,QAAQ,IAAI,cAAc,KAAK;AAE3D,cAAM,SAAS,MAAM,KAAK,IAAI,OAAO,EAAE,UAAU,YAAY,CAAC;AAE9D,eAAO,IAAI,SAAS,KAAK,UAAU,QAAQ,MAAM,CAAC,GAAG;AAAA,UACnD,QAAQ;AAAA,UACR,SAAS,KAAK,eAAe;AAAA,YAC3B,gBAAgB;AAAA,UAClB,CAAC;AAAA,QACH,CAAC;AAAA,MACH,SAAS,OAAO;AACd,eAAO,IAAI;AAAA,UACT,KAAK,UAAU,EAAE,OAAO,iBAAiB,QAAQ,MAAM,UAAU,gBAAgB,CAAC;AAAA,UAClF;AAAA,YACE,QAAQ;AAAA,YACR,SAAS,KAAK,eAAe,EAAE,gBAAgB,mBAAmB,CAAC;AAAA,UACrE;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAKA,QAAI,WAAW,SAAS,IAAI,SAAS,WAAW,KAAK,GAAG;AACtD,YAAM,WAAW,IAAI,SAAS,QAAQ,OAAO,EAAE;AAC/C,YAAM,OAAO,SAAS,MAAM,GAAG,EAAE,CAAC;AAElC,UAAI;AACF,cAAM,WAAW,MAAM,KAAK,IAAI,IAAI;AAEpC,YAAI,CAAC,UAAU;AACb,iBAAO,IAAI,SAAS,aAAa;AAAA,YAC/B,QAAQ;AAAA,YACR,SAAS,KAAK,eAAe;AAAA,UAC/B,CAAC;AAAA,QACH;AAEA,cAAM,cAAc,SAAS,SAAS,eAAe,KAAK;AAE1D,eAAO,IAAI,SAAS,SAAS,MAAM;AAAA,UACjC,QAAQ;AAAA,UACR,SAAS,KAAK,eAAe;AAAA,YAC3B,gBAAgB;AAAA,YAChB,iBAAiB,mBAAmB,KAAK,WAAW;AAAA,YACpD,QAAQ,IAAI,IAAI;AAAA,UAClB,CAAC;AAAA,QACH,CAAC;AAAA,MACH,SAAS,OAAO;AACd,eAAO,IAAI,SAAS,yBAAyB;AAAA,UAC3C,QAAQ;AAAA,UACR,SAAS,KAAK,eAAe;AAAA,QAC/B,CAAC;AAAA,MACH;AAAA,IACF;AAKA,QAAI,WAAW,YAAY,IAAI,SAAS,WAAW,KAAK,GAAG;AACzD,YAAM,WAAW,IAAI,SAAS,QAAQ,OAAO,EAAE;AAC/C,YAAM,OAAO,SAAS,MAAM,GAAG,EAAE,CAAC;AAElC,UAAI;AACF,cAAM,UAAU,MAAM,KAAK,OAAO,IAAI;AAEtC,YAAI,CAAC,SAAS;AACZ,iBAAO,IAAI,SAAS,aAAa;AAAA,YAC/B,QAAQ;AAAA,YACR,SAAS,KAAK,eAAe;AAAA,UAC/B,CAAC;AAAA,QACH;AAEA,eAAO,IAAI,SAAS,KAAK,UAAU,EAAE,SAAS,MAAM,KAAK,CAAC,GAAG;AAAA,UAC3D,QAAQ;AAAA,UACR,SAAS,KAAK,eAAe,EAAE,gBAAgB,mBAAmB,CAAC;AAAA,QACrE,CAAC;AAAA,MACH,SAAS,OAAO;AACd,eAAO,IAAI,SAAS,yBAAyB;AAAA,UAC3C,QAAQ;AAAA,UACR,SAAS,KAAK,eAAe;AAAA,QAC/B,CAAC;AAAA,MACH;AAAA,IACF;AAKA,QAAI,WAAW,WAAW;AACxB,aAAO,IAAI,SAAS,MAAM;AAAA,QACxB,QAAQ;AAAA,QACR,SAAS,KAAK,eAAe;AAAA,MAC/B,CAAC;AAAA,IACH;AAKA,WAAO,IAAI,SAAS,aAAa;AAAA,MAC/B,QAAQ;AAAA,MACR,SAAS,KAAK,eAAe;AAAA,IAC/B,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKQ,eAAe,oBAA4C,CAAC,GAA2B;AAC7F,QAAI,CAAC,KAAK,KAAM,QAAO;AAEvB,WAAO;AAAA,MACL,+BAA+B;AAAA,MAC/B,gCAAgC;AAAA,MAChC,gCAAgC;AAAA,MAChC,0BAA0B;AAAA,MAC1B,GAAG;AAAA,IACL;AAAA,EACF;AACF;;;ACtSO,IAAM,gBAAN,MAA8C;AAAA,EAA9C;AACL,SAAQ,UAA4E,oBAAI,IAAI;AAAA;AAAA,EAE5F,MAAM,IAAI,MAAc,SAAmC,WAA6B,CAAC,GAAkB;AACzG,UAAM,QAAQ,mBAAmB,cAAc,IAAI,WAAW,OAAO,IAAI;AACzE,SAAK,QAAQ,IAAI,MAAM;AAAA,MACrB,SAAS;AAAA,MACT,UAAU;AAAA,QACR,MAAM,MAAM;AAAA,QACZ,YAAY,KAAK,IAAI;AAAA,QACrB,GAAG;AAAA,MACL;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,IAAI,MAA8C;AACtD,UAAM,SAAS,KAAK,QAAQ,IAAI,IAAI;AACpC,QAAI,CAAC,OAAQ,QAAO;AAEpB,WAAO;AAAA,MACL;AAAA,MACA,MAAM,OAAO,QAAQ;AAAA,MACrB,UAAU,OAAO;AAAA,IACnB;AAAA,EACF;AAAA,EAEA,MAAM,OAAO,MAAgC;AAC3C,WAAO,KAAK,QAAQ,OAAO,IAAI;AAAA,EACjC;AAAA,EAEA,MAAM,OAAO,MAAgC;AAC3C,WAAO,KAAK,QAAQ,IAAI,IAAI;AAAA,EAC9B;AAAA,EAEA,MAAM,KAAK,UAA+C,CAAC,GAAsB;AAC/E,UAAM,SAAS,MAAM,KAAK,KAAK,QAAQ,KAAK,CAAC;AAC7C,UAAM,QAAQ,QAAQ,SAAS;AAC/B,WAAO,OAAO,MAAM,GAAG,KAAK;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA,EAKA,QAAc;AACZ,SAAK,QAAQ,MAAM;AAAA,EACrB;AAAA;AAAA;AAAA;AAAA,EAKA,OAAe;AACb,WAAO,KAAK,QAAQ;AAAA,EACtB;AACF;;;ACbO,IAAM,YAAN,MAA0C;AAAA,EAC/C,YAAoB,QAAkB;AAAlB;AAAA,EAAmB;AAAA,EAEvC,MAAM,IAAI,MAAc,SAAmC,WAA6B,CAAC,GAAkB;AACzG,UAAM,KAAK,OAAO,IAAI,MAAM,SAAS;AAAA,MACnC,cAAc;AAAA,QACZ,aAAa,SAAS;AAAA,MACxB;AAAA,MACA,gBAAgB;AAAA,QACd,UAAU,SAAS,YAAY;AAAA,QAC/B,YAAY,OAAO,SAAS,cAAc,KAAK,IAAI,CAAC;AAAA,QACpD,GAAG,SAAS;AAAA,MACd;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,MAAM,IAAI,MAA8C;AACtD,UAAM,MAAM,MAAM,KAAK,OAAO,IAAI,IAAI;AACtC,QAAI,CAAC,OAAO,CAAC,IAAI,KAAM,QAAO;AAE9B,WAAO;AAAA,MACL;AAAA,MACA,MAAM,IAAI;AAAA,MACV,UAAU;AAAA,QACR,aAAa,IAAI,cAAc;AAAA,QAC/B,UAAU,IAAI,gBAAgB;AAAA,QAC9B,MAAM,IAAI;AAAA,QACV,YAAY,IAAI,gBAAgB,aAAa,OAAO,IAAI,eAAe,UAAU,IAAI;AAAA,QACrF,gBAAgB,IAAI;AAAA,MACtB;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,OAAO,MAAgC;AAC3C,UAAM,SAAS,MAAM,KAAK,OAAO,IAAI;AACrC,QAAI,CAAC,OAAQ,QAAO;AACpB,UAAM,KAAK,OAAO,OAAO,IAAI;AAC7B,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,OAAO,MAAgC;AAC3C,UAAM,MAAM,MAAM,KAAK,OAAO,KAAK,IAAI;AACvC,WAAO,QAAQ;AAAA,EACjB;AAAA,EAEA,MAAM,KAAK,UAA+C,CAAC,GAAsB;AAC/E,QAAI,CAAC,KAAK,OAAO,MAAM;AACrB,YAAM,IAAI,MAAM,6CAA6C;AAAA,IAC/D;AAEA,UAAM,SAAS,MAAM,KAAK,OAAO,KAAK;AAAA,MACpC,OAAO,QAAQ,SAAS;AAAA,MACxB,QAAQ,QAAQ;AAAA,IAClB,CAAC;AAED,WAAO,OAAO,QAAQ,IAAI,CAAC,QAAQ,IAAI,GAAG;AAAA,EAC5C;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hammr/cdn",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Content-addressable CDN with storage abstraction (R2, S3, Memory, FileSystem)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cdn",
|
|
21
|
+
"storage",
|
|
22
|
+
"r2",
|
|
23
|
+
"s3",
|
|
24
|
+
"content-addressable",
|
|
25
|
+
"cloudflare",
|
|
26
|
+
"artifacts",
|
|
27
|
+
"cache",
|
|
28
|
+
"sha256"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"test:coverage": "vitest run --coverage",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"prepublishOnly": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@hammr/normalizer": "^1.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^20.10.0",
|
|
43
|
+
"tsup": "^8.0.1",
|
|
44
|
+
"typescript": "^5.3.3",
|
|
45
|
+
"vitest": "^1.0.4",
|
|
46
|
+
"@vitest/coverage-v8": "^1.0.4"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"author": "Edge Foundry, Inc.",
|
|
52
|
+
"license": "UNLICENSED"
|
|
53
|
+
}
|