webhanger 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/bin/cli.js ADDED
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ import inquirer from "inquirer";
3
+ import fs from "fs-extra";
4
+ import chalk from "chalk";
5
+ import path from "path";
6
+ import { generateSecretKey } from "../helper/signer.js";
7
+ import { deploy } from "../core/registry.js";
8
+ import loadConfig from "../helper/loadConfig.js";
9
+ import { provisionBucket, provisionCloudFront } from "../helper/awsProvisioner.js";
10
+
11
+ const args = process.argv.slice(2);
12
+ const command = args[0];
13
+
14
+ const BANNER = `
15
+ ██╗ ██╗███████╗██████╗ ██╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███████╗██████╗
16
+ ██║ ██║██╔════╝██╔══██╗██║ ██║██╔══██╗████╗ ██║██╔════╝ ██╔════╝██╔══██╗
17
+ ██║ █╗ ██║█████╗ ██████╔╝███████║███████║██╔██╗ ██║██║ ███╗█████╗ ██████╔╝
18
+ ██║███╗██║██╔══╝ ██╔══██╗██╔══██║██╔══██║██║╚██╗██║██║ ██║██╔══╝ ██╔══██╗
19
+ ╚███╔███╔╝███████╗██████╔╝██║ ██║██║ ██║██║ ╚████║╚██████╔╝███████╗██║ ██║
20
+ ╚══╝╚══╝ ╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝
21
+ `;
22
+
23
+ async function init() {
24
+ console.log(chalk.cyan(BANNER));
25
+
26
+ console.log(chalk.bold.white("\n📦 Storage Providers:"));
27
+ console.log(chalk.gray(" r2 — Cloudflare R2 (zero egress fees, global edge)"));
28
+ console.log(chalk.gray(" s3 — AWS S3 (industry standard, global regions)"));
29
+ console.log(chalk.gray(" minio — Self-hosted MinIO (full control, S3-compatible)"));
30
+ console.log(chalk.gray(" local — Local disk (dev/testing only, no CDN)\n"));
31
+
32
+ console.log(chalk.bold.white("🗄️ Database Providers:"));
33
+ console.log(chalk.gray(" firebase — Firebase Firestore (free tier, real-time, globally distributed)"));
34
+ console.log(chalk.gray(" supabase — Supabase Postgres (open source, free tier, REST + realtime)"));
35
+ console.log(chalk.gray(" mongodb — MongoDB Atlas (flexible documents, free tier)\n"));
36
+
37
+ const answers = await inquirer.prompt([
38
+ {
39
+ type: "input",
40
+ name: "projectName",
41
+ message: "Project name:",
42
+ default: "my-webhanger-app"
43
+ },
44
+
45
+ // ── STORAGE ──────────────────────────────────────────────────────────
46
+ {
47
+ type: "list",
48
+ name: "storageProvider",
49
+ message: "Select storage provider:",
50
+ choices: [
51
+ { name: "r2 — Cloudflare R2", value: "r2" },
52
+ { name: "s3 — AWS S3", value: "s3" },
53
+ { name: "minio — Self-hosted MinIO", value: "minio" },
54
+ { name: "local — Local disk (dev only)", value: "local" }
55
+ ]
56
+ },
57
+ {
58
+ type: "input",
59
+ name: "localPath",
60
+ message: "Local storage path:",
61
+ default: "./storage",
62
+ when: (a) => a.storageProvider === "local"
63
+ },
64
+ {
65
+ type: "input",
66
+ name: "endpoint",
67
+ message: "Endpoint URL (e.g. https://<id>.r2.cloudflarestorage.com or http://localhost:9000):",
68
+ when: (a) => a.storageProvider === "r2" || a.storageProvider === "minio"
69
+ },
70
+ {
71
+ type: "input",
72
+ name: "accessKey",
73
+ message: "Storage Access Key:",
74
+ when: (a) => a.storageProvider !== "local"
75
+ },
76
+ {
77
+ type: "password",
78
+ name: "storageSecret",
79
+ message: "Storage Secret Key:",
80
+ when: (a) => a.storageProvider !== "local"
81
+ },
82
+ {
83
+ type: "input",
84
+ name: "bucket",
85
+ message: "Bucket name:",
86
+ when: (a) => a.storageProvider !== "local"
87
+ },
88
+ {
89
+ type: "input",
90
+ name: "region",
91
+ message: "Region (e.g. us-east-1):",
92
+ default: "us-east-1",
93
+ when: (a) => a.storageProvider === "s3"
94
+ },
95
+
96
+ // ── CDN ───────────────────────────────────────────────────────────────
97
+ {
98
+ type: "input",
99
+ name: "cdnBase",
100
+ message: "CDN base URL (your edge URL pointing to storage):",
101
+ when: (a) => a.storageProvider !== "local" && a.storageProvider !== "s3"
102
+ },
103
+
104
+ // ── DATABASE ──────────────────────────────────────────────────────────
105
+ {
106
+ type: "list",
107
+ name: "dbProvider",
108
+ message: "Select database provider:",
109
+ choices: [
110
+ { name: "firebase — Firebase Firestore", value: "firebase" },
111
+ { name: "supabase — Supabase Postgres", value: "supabase" },
112
+ { name: "mongodb — MongoDB Atlas", value: "mongodb" }
113
+ ]
114
+ },
115
+
116
+ // Firebase
117
+ {
118
+ type: "input",
119
+ name: "firebaseServiceAccount",
120
+ message: "Path to Firebase service account JSON:",
121
+ default: "./firebase-service-account.json",
122
+ when: (a) => a.dbProvider === "firebase"
123
+ },
124
+
125
+ // Supabase
126
+ {
127
+ type: "input",
128
+ name: "supabaseUrl",
129
+ message: "Supabase project URL (https://xxx.supabase.co):",
130
+ when: (a) => a.dbProvider === "supabase"
131
+ },
132
+ {
133
+ type: "password",
134
+ name: "supabaseKey",
135
+ message: "Supabase service role key:",
136
+ when: (a) => a.dbProvider === "supabase"
137
+ },
138
+
139
+ // MongoDB
140
+ {
141
+ type: "input",
142
+ name: "mongoUri",
143
+ message: "MongoDB connection URI:",
144
+ when: (a) => a.dbProvider === "mongodb"
145
+ }
146
+ ]);
147
+
148
+ const projectId = `wh_${Date.now()}`;
149
+ const secretKey = generateSecretKey();
150
+
151
+ // Auto-provision S3 bucket + CloudFront
152
+ let cdnUrl = answers.storageProvider === "local" ? "http://localhost" : answers.cdnBase?.replace(/\/$/, "");
153
+ let distributionId = null;
154
+ const bucketName = answers.bucket || `webhanger-${projectId}`;
155
+
156
+ if (answers.storageProvider === "s3") {
157
+ console.log(chalk.cyan("\n⚙️ Provisioning AWS infrastructure..."));
158
+ try {
159
+ await provisionBucket(answers.accessKey, answers.storageSecret, answers.region, bucketName);
160
+ const cf = await provisionCloudFront(answers.accessKey, answers.storageSecret, bucketName, answers.region);
161
+ cdnUrl = cf.cdnUrl;
162
+ distributionId = cf.distributionId;
163
+ } catch (err) {
164
+ console.log(chalk.red(`\n❌ AWS provisioning failed: ${err.message}`));
165
+ console.log(chalk.yellow("Check your IAM permissions (S3FullAccess + CloudFrontFullAccess required)."));
166
+ process.exit(1);
167
+ }
168
+ }
169
+
170
+ // Build DB config based on provider
171
+ const dbConfig = answers.dbProvider === "firebase"
172
+ ? { provider: "firebase", serviceAccountPath: answers.firebaseServiceAccount }
173
+ : answers.dbProvider === "supabase"
174
+ ? { provider: "supabase", url: answers.supabaseUrl, key: answers.supabaseKey }
175
+ : { provider: "mongodb", uri: answers.mongoUri };
176
+
177
+ const config = {
178
+ project: answers.projectName,
179
+ projectId,
180
+ secretKey,
181
+ webHangerVersion: "1.0.0",
182
+ storage: {
183
+ provider: answers.storageProvider,
184
+ ...(answers.storageProvider === "local"
185
+ ? { localPath: answers.localPath }
186
+ : {
187
+ accessKey: answers.accessKey,
188
+ secretKey: answers.storageSecret,
189
+ bucket: bucketName,
190
+ region: answers.region || "auto",
191
+ ...(answers.endpoint ? { endpoint: answers.endpoint } : {}),
192
+ ...(distributionId ? { distributionId } : {})
193
+ })
194
+ },
195
+ cdn: { url: cdnUrl },
196
+ db: dbConfig
197
+ };
198
+
199
+ await fs.writeJson("./webhanger.config.json", config, { spaces: 2 });
200
+ console.log(chalk.green("\n✅ webhanger.config.json created."));
201
+ console.log(chalk.yellow(`🔑 Project ID: ${projectId}`));
202
+ console.log(chalk.gray("Keep your secretKey safe — it signs all your component URLs.\n"));
203
+ }
204
+
205
+ async function deployCommand() {
206
+ const [, , , componentDir, name, version] = process.argv;
207
+
208
+ if (!componentDir || !name || !version) {
209
+ console.log(chalk.red("Usage: wh deploy <component-dir> <name> <version>"));
210
+ console.log(chalk.gray("Example: wh deploy ./components/navbar navbar 1.0.0"));
211
+ process.exit(1);
212
+ }
213
+
214
+ const config = loadConfig();
215
+
216
+ // Ask user about token + expiry
217
+ const options = await inquirer.prompt([
218
+ {
219
+ type: "confirm",
220
+ name: "useCustomToken",
221
+ message: "Set a custom token? (No = auto-generate):",
222
+ default: false
223
+ },
224
+ {
225
+ type: "input",
226
+ name: "customToken",
227
+ message: "Enter your custom token:",
228
+ when: (a) => a.useCustomToken
229
+ },
230
+ {
231
+ type: "confirm",
232
+ name: "setExpiry",
233
+ message: "Set an expiry for this component token?",
234
+ default: false
235
+ },
236
+ {
237
+ type: "input",
238
+ name: "expirySeconds",
239
+ message: "Expiry in seconds (e.g. 3600 = 1hr, 86400 = 1day, 2592000 = 30days):",
240
+ when: (a) => a.setExpiry,
241
+ validate: (v) => !isNaN(parseInt(v)) || "Please enter a valid number"
242
+ }
243
+ ]);
244
+
245
+ const expiresInSeconds = options.setExpiry ? parseInt(options.expirySeconds) : null;
246
+ const customToken = options.useCustomToken ? options.customToken : null;
247
+
248
+ console.log(chalk.cyan(`\n🚀 Deploying ${name}@${version}...`));
249
+
250
+ try {
251
+ const result = await deploy(config, componentDir, name, version, [], expiresInSeconds, customToken);
252
+ console.log(chalk.green(`\n✅ Deployed successfully!`));
253
+ console.log(chalk.white(`📦 CDN URL : ${result.cdnUrl}`));
254
+ console.log(chalk.white(`🔐 Token : ${result.token}`));
255
+ if (result.expires === 0) {
256
+ console.log(chalk.gray(`⏱ Expires : never`));
257
+ } else {
258
+ console.log(chalk.white(`⏱ Expires : ${result.expires}`));
259
+ console.log(chalk.gray(` (${new Date(result.expires * 1000).toISOString()})`));
260
+ }
261
+ } catch (err) {
262
+ console.log(chalk.red(`\n❌ Deploy failed: ${err.message}`));
263
+ process.exit(1);
264
+ }
265
+ }
266
+
267
+ // Router
268
+ switch (command) {
269
+ case "init":
270
+ init();
271
+ break;
272
+ case "deploy":
273
+ deployCommand();
274
+ break;
275
+ default:
276
+ console.log(chalk.cyan(BANNER));
277
+ console.log(chalk.white("Commands:"));
278
+ console.log(chalk.gray(" wh init — setup your project"));
279
+ console.log(chalk.gray(" wh deploy <dir> <name> <version> — bundle & deploy a component"));
280
+ break;
281
+ }
@@ -0,0 +1,38 @@
1
+ import { bundle } from "../helper/bundler.js";
2
+ import { upload } from "../helper/bucketHandler.js";
3
+ import { signUrl } from "../helper/signer.js";
4
+ import { registerComponent } from "../helper/dbHandler.js";
5
+
6
+ /**
7
+ * Full deploy flow:
8
+ * 1. Bundle component folder → single JS
9
+ * 2. Upload to storage
10
+ * 3. Generate signed CDN URL
11
+ * 4. Register metadata in Firestore
12
+ */
13
+ export async function deploy(config, componentDir, name, version, dependencies = [], expiresInSeconds = null, customToken = null) {
14
+ const { projectId, secretKey, storage, cdn, db } = config;
15
+
16
+ // 1. Bundle + encode
17
+ const bundledJs = await bundle(componentDir, projectId);
18
+
19
+ // 2. Upload
20
+ const storageKey = `components/${name}@${version}.js`;
21
+ await upload(storage, storageKey, bundledJs);
22
+
23
+ // 3. Sign — use custom token if provided, otherwise HMAC-generate
24
+ const cdnUrl = `${cdn.url}/${storageKey}`;
25
+ let token, expires;
26
+
27
+ if (customToken) {
28
+ expires = expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0;
29
+ token = customToken;
30
+ } else {
31
+ ({ token, expires } = signUrl(storageKey, projectId, secretKey, expiresInSeconds));
32
+ }
33
+
34
+ // 4. Register in DB
35
+ await registerComponent(db, projectId, { name, version, cdnUrl, token, expires, dependencies });
36
+
37
+ return { cdnUrl, token, expires };
38
+ }
@@ -0,0 +1,160 @@
1
+ import {
2
+ S3Client,
3
+ CreateBucketCommand,
4
+ PutBucketVersioningCommand,
5
+ PutBucketPolicyCommand,
6
+ PutPublicAccessBlockCommand,
7
+ PutBucketCorsCommand,
8
+ HeadBucketCommand
9
+ } from "@aws-sdk/client-s3";
10
+
11
+ import {
12
+ CloudFrontClient,
13
+ CreateDistributionCommand,
14
+ GetDistributionCommand
15
+ } from "@aws-sdk/client-cloudfront";
16
+
17
+ function getS3(accessKey, secretKey, region) {
18
+ return new S3Client({
19
+ region,
20
+ credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }
21
+ });
22
+ }
23
+
24
+ function getCF(accessKey, secretKey) {
25
+ return new CloudFrontClient({
26
+ region: "us-east-1", // CloudFront is always us-east-1
27
+ credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Creates the S3 bucket if it doesn't exist.
33
+ * Enables versioning and sets a private policy.
34
+ */
35
+ export async function provisionBucket(accessKey, secretKey, region, bucketName) {
36
+ const s3 = getS3(accessKey, secretKey, region);
37
+
38
+ // Check if bucket already exists
39
+ try {
40
+ await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
41
+ console.log(` ✓ Bucket "${bucketName}" already exists, skipping creation.`);
42
+ } catch {
43
+ // Create bucket
44
+ const createParams = { Bucket: bucketName };
45
+ // us-east-1 does NOT accept LocationConstraint
46
+ if (region !== "us-east-1") {
47
+ createParams.CreateBucketConfiguration = { LocationConstraint: region };
48
+ }
49
+ await s3.send(new CreateBucketCommand(createParams));
50
+ console.log(` ✓ Bucket "${bucketName}" created.`);
51
+ }
52
+
53
+ // Disable block public access so bucket policy can allow CloudFront reads
54
+ await s3.send(new PutPublicAccessBlockCommand({
55
+ Bucket: bucketName,
56
+ PublicAccessBlockConfiguration: {
57
+ BlockPublicAcls: false,
58
+ IgnorePublicAcls: false,
59
+ BlockPublicPolicy: false,
60
+ RestrictPublicBuckets: false
61
+ }
62
+ }));
63
+ console.log(` ✓ Public access block disabled.`);
64
+
65
+ // Enable versioning
66
+ await s3.send(new PutBucketVersioningCommand({
67
+ Bucket: bucketName,
68
+ VersioningConfiguration: { Status: "Enabled" }
69
+ }));
70
+ console.log(` ✓ Versioning enabled.`);
71
+
72
+ // Allow public read for CloudFront delivery
73
+ const policy = JSON.stringify({
74
+ Version: "2012-10-17",
75
+ Statement: [{
76
+ Sid: "AllowCloudFrontRead",
77
+ Effect: "Allow",
78
+ Principal: "*",
79
+ Action: "s3:GetObject",
80
+ Resource: `arn:aws:s3:::${bucketName}/*`
81
+ }]
82
+ });
83
+
84
+ await s3.send(new PutBucketPolicyCommand({ Bucket: bucketName, Policy: policy }));
85
+ console.log(` ✓ Bucket policy set (public read for CDN delivery).`);
86
+
87
+ // Set CORS so CloudFront can serve to browsers
88
+ await s3.send(new PutBucketCorsCommand({
89
+ Bucket: bucketName,
90
+ CORSConfiguration: {
91
+ CORSRules: [{
92
+ AllowedHeaders: ["*"],
93
+ AllowedMethods: ["GET", "HEAD"],
94
+ AllowedOrigins: ["*"],
95
+ ExposeHeaders: [],
96
+ MaxAgeSeconds: 3000
97
+ }]
98
+ }
99
+ }));
100
+ console.log(` ✓ CORS configured.`);
101
+ }
102
+
103
+ /**
104
+ * Creates a CloudFront distribution pointing to the S3 bucket.
105
+ * Restricts access — files only served with a valid signed token (viewer policy: https only).
106
+ * Returns the CloudFront domain URL.
107
+ */
108
+ export async function provisionCloudFront(accessKey, secretKey, bucketName, region) {
109
+ const cf = getCF(accessKey, secretKey);
110
+ const s3Origin = `${bucketName}.s3.${region}.amazonaws.com`;
111
+
112
+ const params = {
113
+ DistributionConfig: {
114
+ CallerReference: `wh-${Date.now()}`,
115
+ Comment: `WebHanger CDN for ${bucketName}`,
116
+ Enabled: true,
117
+ DefaultCacheBehavior: {
118
+ TargetOriginId: "s3-origin",
119
+ ViewerProtocolPolicy: "https-only",
120
+ CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", // CachingOptimized
121
+ OriginRequestPolicyId: "88a5eaf4-2fd4-4709-b370-b4c650ea3fcf", // CORS-S3Origin
122
+ ResponseHeadersPolicyId: "60669652-455b-4ae9-85a4-c4c02393f86c", // CORS-With-Preflight
123
+ AllowedMethods: {
124
+ Quantity: 2,
125
+ Items: ["GET", "HEAD"]
126
+ },
127
+ TrustedSigners: {
128
+ Enabled: false,
129
+ Quantity: 0
130
+ }
131
+ },
132
+ Origins: {
133
+ Quantity: 1,
134
+ Items: [{
135
+ Id: "s3-origin",
136
+ DomainName: s3Origin,
137
+ S3OriginConfig: { OriginAccessIdentity: "" },
138
+ CustomHeaders: { // custom header so S3 knows request is from CF
139
+ Quantity: 1,
140
+ Items: [{
141
+ HeaderName: "x-wh-origin",
142
+ HeaderValue: "cloudfront"
143
+ }]
144
+ }
145
+ }]
146
+ },
147
+ HttpVersion: "http2",
148
+ PriceClass: "PriceClass_All" // global edge nodes
149
+ }
150
+ };
151
+
152
+ const res = await cf.send(new CreateDistributionCommand(params));
153
+ const domain = res.Distribution.DomainName;
154
+ const distributionId = res.Distribution.Id;
155
+
156
+ console.log(` ✓ CloudFront distribution created: https://${domain}`);
157
+ console.log(` ⏳ Note: CloudFront takes ~10-15 min to fully deploy globally.`);
158
+
159
+ return { cdnUrl: `https://${domain}`, distributionId };
160
+ }
@@ -0,0 +1,61 @@
1
+ import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
2
+ import fs from "fs-extra";
3
+ import path from "path";
4
+
5
+ function getS3Client(storage) {
6
+ const config = {
7
+ region: storage.region || "auto",
8
+ credentials: {
9
+ accessKeyId: storage.accessKey,
10
+ secretAccessKey: storage.secretKey
11
+ }
12
+ };
13
+
14
+ // R2 and MinIO need a custom endpoint
15
+ if (storage.provider === "r2" || storage.provider === "minio") {
16
+ config.endpoint = storage.endpoint;
17
+ config.forcePathStyle = true; // required for MinIO
18
+ }
19
+
20
+ return new S3Client(config);
21
+ }
22
+
23
+ /**
24
+ * Uploads a file buffer to the configured storage provider.
25
+ * Returns the storage path (key) of the uploaded file.
26
+ */
27
+ export async function upload(storage, key, content) {
28
+ if (storage.provider === "local") {
29
+ const dest = path.join(storage.localPath, key);
30
+ await fs.ensureDir(path.dirname(dest));
31
+ await fs.writeFile(dest, content, "utf-8");
32
+ return key;
33
+ }
34
+
35
+ const client = getS3Client(storage);
36
+ await client.send(new PutObjectCommand({
37
+ Bucket: storage.bucket,
38
+ Key: key,
39
+ Body: content,
40
+ ContentType: "application/javascript"
41
+ }));
42
+
43
+ return key;
44
+ }
45
+
46
+ /**
47
+ * Deletes a file from storage.
48
+ */
49
+ export async function remove(storage, key) {
50
+ if (storage.provider === "local") {
51
+ const dest = path.join(storage.localPath, key);
52
+ await fs.remove(dest);
53
+ return;
54
+ }
55
+
56
+ const client = getS3Client(storage);
57
+ await client.send(new DeleteObjectCommand({
58
+ Bucket: storage.bucket,
59
+ Key: key
60
+ }));
61
+ }
@@ -0,0 +1,48 @@
1
+ import fs from "fs-extra";
2
+ import path from "path";
3
+
4
+ function xorEncode(str, key) {
5
+ let out = "";
6
+ for (let i = 0; i < str.length; i++) {
7
+ out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
8
+ }
9
+ return out;
10
+ }
11
+
12
+ /**
13
+ * Bundles html+css+js into an encrypted JSON payload.
14
+ * Each chunk is XOR-encoded with projectId + chunk-type salt, then base64.
15
+ * No eval needed — SDK decrypts and applies each chunk directly to DOM.
16
+ */
17
+ export async function bundle(componentDir, projectId) {
18
+ const files = await fs.readdir(componentDir);
19
+
20
+ let html = "", css = "", js = "";
21
+
22
+ for (const file of files) {
23
+ const filePath = path.join(componentDir, file);
24
+ const ext = path.extname(file).toLowerCase();
25
+ const content = await fs.readFile(filePath, "utf-8");
26
+
27
+ if (ext === ".html") html = content.trim();
28
+ else if (ext === ".css") css = content.trim();
29
+ else if (ext === ".js") js = content.trim();
30
+ }
31
+
32
+ if (!html && !js) {
33
+ throw new Error("Component must have at least an .html or .js file.");
34
+ }
35
+
36
+ // Each chunk encrypted with projectId + salt — different key per chunk type
37
+ const encrypt = (content, salt) =>
38
+ content ? Buffer.from(xorEncode(content, projectId + salt)).toString("base64") : "";
39
+
40
+ const payload = {
41
+ v: 1,
42
+ h: encrypt(html, "::html"),
43
+ c: encrypt(css, "::css"),
44
+ j: encrypt(js, "::js")
45
+ };
46
+
47
+ return JSON.stringify(payload);
48
+ }
@@ -0,0 +1,122 @@
1
+ import admin from "firebase-admin";
2
+ import fs from "fs-extra";
3
+
4
+ // ─── Firebase ─────────────────────────────────────────────────────────────────
5
+
6
+ let firebaseDb = null;
7
+
8
+ function getFirestore(serviceAccountPath) {
9
+ if (firebaseDb) return firebaseDb;
10
+ const serviceAccount = fs.readJsonSync(serviceAccountPath);
11
+ if (!admin.apps.length) {
12
+ admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
13
+ }
14
+ firebaseDb = admin.firestore();
15
+ return firebaseDb;
16
+ }
17
+
18
+ async function firebaseRegister(db, projectId, meta) {
19
+ const { name, version, cdnUrl, token, expires, dependencies = [] } = meta;
20
+ await db
21
+ .collection("projects").doc(projectId)
22
+ .collection("components").doc(name)
23
+ .collection("versions").doc(version)
24
+ .set({ name, version, cdnUrl, token, expires, dependencies, createdAt: admin.firestore.FieldValue.serverTimestamp() });
25
+ }
26
+
27
+ async function firebaseGet(db, projectId, name, version) {
28
+ const snap = await db
29
+ .collection("projects").doc(projectId)
30
+ .collection("components").doc(name)
31
+ .collection("versions").doc(version)
32
+ .get();
33
+ return snap.exists ? snap.data() : null;
34
+ }
35
+
36
+ // ─── Supabase ─────────────────────────────────────────────────────────────────
37
+
38
+ async function getSupabase(url, key) {
39
+ const { createClient } = await import("@supabase/supabase-js");
40
+ return createClient(url, key);
41
+ }
42
+
43
+ async function supabaseRegister(client, projectId, meta) {
44
+ const { error } = await client.from("wh_components").upsert({
45
+ project_id: projectId,
46
+ name: meta.name,
47
+ version: meta.version,
48
+ cdn_url: meta.cdnUrl,
49
+ token: meta.token,
50
+ expires: meta.expires,
51
+ dependencies: meta.dependencies || [],
52
+ created_at: new Date().toISOString()
53
+ });
54
+ if (error) throw new Error(`Supabase error: ${error.message}`);
55
+ }
56
+
57
+ async function supabaseGet(client, projectId, name, version) {
58
+ const { data, error } = await client
59
+ .from("wh_components")
60
+ .select("*")
61
+ .eq("project_id", projectId)
62
+ .eq("name", name)
63
+ .eq("version", version)
64
+ .single();
65
+ if (error) return null;
66
+ return data ? { ...data, cdnUrl: data.cdn_url } : null;
67
+ }
68
+
69
+ // ─── MongoDB ──────────────────────────────────────────────────────────────────
70
+
71
+ async function getMongo(uri) {
72
+ const { MongoClient } = await import("mongodb");
73
+ const client = new MongoClient(uri);
74
+ await client.connect();
75
+ return client.db("webhanger");
76
+ }
77
+
78
+ async function mongoRegister(db, projectId, meta) {
79
+ await db.collection("components").updateOne(
80
+ { projectId, name: meta.name, version: meta.version },
81
+ { $set: { ...meta, projectId, createdAt: new Date() } },
82
+ { upsert: true }
83
+ );
84
+ }
85
+
86
+ async function mongoGet(db, projectId, name, version) {
87
+ return await db.collection("components").findOne({ projectId, name, version });
88
+ }
89
+
90
+ // ─── Public API ───────────────────────────────────────────────────────────────
91
+
92
+ export async function registerComponent(dbConfig, projectId, meta) {
93
+ if (dbConfig.provider === "firebase") {
94
+ const db = getFirestore(dbConfig.serviceAccountPath);
95
+ return firebaseRegister(db, projectId, meta);
96
+ }
97
+ if (dbConfig.provider === "supabase") {
98
+ const client = await getSupabase(dbConfig.url, dbConfig.key);
99
+ return supabaseRegister(client, projectId, meta);
100
+ }
101
+ if (dbConfig.provider === "mongodb") {
102
+ const db = await getMongo(dbConfig.uri);
103
+ return mongoRegister(db, projectId, meta);
104
+ }
105
+ throw new Error(`Unknown DB provider: ${dbConfig.provider}`);
106
+ }
107
+
108
+ export async function getComponent(dbConfig, projectId, name, version) {
109
+ if (dbConfig.provider === "firebase") {
110
+ const db = getFirestore(dbConfig.serviceAccountPath);
111
+ return firebaseGet(db, projectId, name, version);
112
+ }
113
+ if (dbConfig.provider === "supabase") {
114
+ const client = await getSupabase(dbConfig.url, dbConfig.key);
115
+ return supabaseGet(client, projectId, name, version);
116
+ }
117
+ if (dbConfig.provider === "mongodb") {
118
+ const db = await getMongo(dbConfig.uri);
119
+ return mongoGet(db, projectId, name, version);
120
+ }
121
+ throw new Error(`Unknown DB provider: ${dbConfig.provider}`);
122
+ }
@@ -0,0 +1,59 @@
1
+ import admin from "firebase-admin";
2
+ import fs from "fs-extra";
3
+
4
+ let db = null;
5
+
6
+ export function initFirebase(serviceAccountPath) {
7
+ if (db) return db; // already initialized
8
+
9
+ const serviceAccount = fs.readJsonSync(serviceAccountPath);
10
+
11
+ if (!admin.apps.length) {
12
+ admin.initializeApp({
13
+ credential: admin.credential.cert(serviceAccount)
14
+ });
15
+ }
16
+
17
+ db = admin.firestore();
18
+ return db;
19
+ }
20
+
21
+ /**
22
+ * Registers a component in Firestore under:
23
+ * projects/{projectId}/components/{name}/versions/{version}
24
+ */
25
+ export async function registerComponent(serviceAccountPath, projectId, componentMeta) {
26
+ const db = initFirebase(serviceAccountPath);
27
+ const { name, version, cdnUrl, token, expires, dependencies = [] } = componentMeta;
28
+
29
+ const ref = db
30
+ .collection("projects").doc(projectId)
31
+ .collection("components").doc(name)
32
+ .collection("versions").doc(version);
33
+
34
+ await ref.set({
35
+ name,
36
+ version,
37
+ cdnUrl,
38
+ token,
39
+ expires,
40
+ dependencies,
41
+ createdAt: admin.firestore.FieldValue.serverTimestamp()
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Fetches a specific component version metadata.
47
+ */
48
+ export async function getComponent(serviceAccountPath, projectId, name, version) {
49
+ const db = initFirebase(serviceAccountPath);
50
+
51
+ const ref = db
52
+ .collection("projects").doc(projectId)
53
+ .collection("components").doc(name)
54
+ .collection("versions").doc(version);
55
+
56
+ const snap = await ref.get();
57
+ if (!snap.exists) return null;
58
+ return snap.data();
59
+ }
@@ -0,0 +1,19 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+
4
+ export default function loadConfig() {
5
+ const configPath = path.join(process.cwd(), "webhanger.config.json");
6
+
7
+ if (!fs.existsSync(configPath)) {
8
+ throw new Error("webhanger.config.json not found. Run `wh init` first.");
9
+ }
10
+
11
+ const raw = fs.readFileSync(configPath, "utf-8");
12
+ const config = JSON.parse(raw);
13
+
14
+ if (!config.projectId || !config.secretKey) {
15
+ throw new Error("Invalid config: missing projectId or secretKey.");
16
+ }
17
+
18
+ return config;
19
+ }
@@ -0,0 +1,30 @@
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * Signs a component URL with HMAC-SHA256.
5
+ * expiresInSeconds is optional — if not provided, token never expires.
6
+ */
7
+ export function signUrl(componentPath, projectId, secretKey, expiresInSeconds = null) {
8
+ const expires = expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0; // 0 = no expiry
9
+ const payload = `${projectId}:${componentPath}:${expires}`;
10
+ const token = crypto.createHmac("sha256", secretKey).update(payload).digest("hex");
11
+ return { token, expires };
12
+ }
13
+
14
+ /**
15
+ * Verifies a signed token.
16
+ * If expires is 0, token never expires.
17
+ */
18
+ export function verifyToken(componentPath, projectId, secretKey, token, expires) {
19
+ if (expires !== 0 && Date.now() / 1000 > expires) return false;
20
+ const payload = `${projectId}:${componentPath}:${expires}`;
21
+ const expected = crypto.createHmac("sha256", secretKey).update(payload).digest("hex");
22
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected));
23
+ }
24
+
25
+ /**
26
+ * Generates a one-time project secret key on init.
27
+ */
28
+ export function generateSecretKey() {
29
+ return crypto.randomBytes(32).toString("hex");
30
+ }
package/index.js ADDED
@@ -0,0 +1,152 @@
1
+ import loadConfig from "./helper/loadConfig.js";
2
+ import { getComponent, registerComponent } from "./helper/dbHandler.js";
3
+ import { verifyToken, signUrl, generateSecretKey } from "./helper/signer.js";
4
+ import { deploy as registryDeploy } from "./core/registry.js";
5
+ import { provisionBucket, provisionCloudFront } from "./helper/awsProvisioner.js";
6
+ import { upload, remove } from "./helper/bucketHandler.js";
7
+ import { bundle } from "./helper/bundler.js";
8
+
9
+ export class WebHanger {
10
+ constructor(configPath = null) {
11
+ this.config = loadConfig(configPath);
12
+ }
13
+
14
+ /**
15
+ * Bundle + upload + sign + register a component.
16
+ * @param {string} componentDir - path to folder with html/css/js
17
+ * @param {string} name - component name e.g. "navbar"
18
+ * @param {string} version - semver string e.g. "1.0.0"
19
+ * @param {object} options - { expiresInSeconds, token, dependencies }
20
+ */
21
+ async deploy(componentDir, name, version, options = {}) {
22
+ const { expiresInSeconds = null, token = null, dependencies = [] } = options;
23
+ return await registryDeploy(
24
+ this.config,
25
+ componentDir,
26
+ name,
27
+ version,
28
+ dependencies,
29
+ expiresInSeconds,
30
+ token
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Resolve a component — fetch metadata + verify token.
36
+ * Returns { cdnUrl, token, expires, dependencies }
37
+ */
38
+ async resolve(name, version = "latest") {
39
+ const { projectId, secretKey, db } = this.config;
40
+ const meta = await getComponent(db, projectId, name, version);
41
+ if (!meta) throw new Error(`Component ${name}@${version} not found.`);
42
+
43
+ if (meta.expires !== 0) {
44
+ const valid = verifyToken(
45
+ `components/${name}@${version}.js`,
46
+ projectId,
47
+ secretKey,
48
+ meta.token,
49
+ meta.expires
50
+ );
51
+ if (!valid) throw new Error(`Token for ${name}@${version} is invalid or expired.`);
52
+ }
53
+
54
+ return {
55
+ cdnUrl: meta.cdnUrl,
56
+ token: meta.token,
57
+ expires: meta.expires,
58
+ dependencies: meta.dependencies
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Delete a component from storage.
64
+ */
65
+ async remove(name, version) {
66
+ const { storage } = this.config;
67
+ const key = `components/${name}@${version}.js`;
68
+ await remove(storage, key);
69
+ }
70
+
71
+ /**
72
+ * Re-sign an existing component with a new token/expiry.
73
+ * Useful for rotating tokens without redeploying.
74
+ */
75
+ async resign(name, version, options = {}) {
76
+ const { expiresInSeconds = null, token = null } = options;
77
+ const { projectId, secretKey, cdn, db } = this.config;
78
+ const key = `components/${name}@${version}.js`;
79
+
80
+ let newToken, expires;
81
+ if (token) {
82
+ expires = expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0;
83
+ newToken = token;
84
+ } else {
85
+ ({ token: newToken, expires } = signUrl(key, projectId, secretKey, expiresInSeconds));
86
+ }
87
+
88
+ const cdnUrl = `${cdn.url}/${key}`;
89
+ const existing = await getComponent(db, projectId, name, version);
90
+
91
+ await registerComponent(db, projectId, {
92
+ name,
93
+ version,
94
+ cdnUrl,
95
+ token: newToken,
96
+ expires,
97
+ dependencies: existing?.dependencies || []
98
+ });
99
+
100
+ return { cdnUrl, token: newToken, expires };
101
+ }
102
+
103
+ /**
104
+ * List all versions of a component from DB.
105
+ */
106
+ async versions(name) {
107
+ const { projectId, db } = this.config;
108
+
109
+ if (db.provider === "firebase") {
110
+ const admin = (await import("firebase-admin")).default;
111
+ const { initFirebase } = await import("./helper/dbHandler.js");
112
+ const firestore = initFirebase ? null : null; // handled inside dbHandler
113
+ // Use raw firestore for listing subcollection
114
+ const { getComponent: _ , ...rest } = await import("./helper/dbHandler.js");
115
+ const dbInstance = await getFirestoreInstance(db.serviceAccountPath);
116
+ const snap = await dbInstance
117
+ .collection("projects").doc(projectId)
118
+ .collection("components").doc(name)
119
+ .collection("versions").get();
120
+ return snap.docs.map(d => d.data());
121
+ }
122
+
123
+ throw new Error(`versions() not yet supported for provider: ${db.provider}`);
124
+ }
125
+
126
+ /**
127
+ * Returns the config loaded for this instance.
128
+ */
129
+ getConfig() {
130
+ return this.config;
131
+ }
132
+ }
133
+
134
+ // Helper for versions() firebase path
135
+ async function getFirestoreInstance(serviceAccountPath) {
136
+ const admin = (await import("firebase-admin")).default;
137
+ const fs = (await import("fs-extra")).default;
138
+ const serviceAccount = fs.readJsonSync(serviceAccountPath);
139
+ if (!admin.apps.length) {
140
+ admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
141
+ }
142
+ return admin.firestore();
143
+ }
144
+
145
+ // Named exports for direct use without instantiation
146
+ export { signUrl, verifyToken, generateSecretKey } from "./helper/signer.js";
147
+ export { bundle } from "./helper/bundler.js";
148
+ export { upload, remove } from "./helper/bucketHandler.js";
149
+ export { registerComponent, getComponent } from "./helper/dbHandler.js";
150
+ export { provisionBucket, provisionCloudFront } from "./helper/awsProvisioner.js";
151
+ export { deploy } from "./core/registry.js";
152
+ export { default as loadConfig } from "./helper/loadConfig.js";
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "webhanger",
3
+ "version": "1.0.0",
4
+ "description": "Component-as-a-Service platform — bundle, sign, and deliver UI components via edge CDN",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "bin/",
10
+ "core/",
11
+ "helper/",
12
+ "sdk/"
13
+ ],
14
+ "bin": {
15
+ "webhanger": "./bin/cli.js",
16
+ "wh": "./bin/cli.js"
17
+ },
18
+ "scripts": {
19
+ "test": "echo \"Error: no test specified\" && exit 1"
20
+ },
21
+ "keywords": ["cdn", "components", "caas", "edge", "ui"],
22
+ "author": "",
23
+ "license": "ISC",
24
+ "dependencies": {
25
+ "@aws-sdk/client-s3": "^3.0.0",
26
+ "@aws-sdk/client-cloudfront": "^3.0.0",
27
+ "@aws-sdk/s3-request-presigner": "^3.0.0",
28
+ "chalk": "^5.6.2",
29
+ "firebase-admin": "^12.0.0",
30
+ "fs-extra": "^11.3.4",
31
+ "inquirer": "^13.4.1"
32
+ }
33
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * WebHanger Browser SDK
3
+ * Fetches encrypted component payload from CDN.
4
+ * Decrypts each chunk (html/css/js) and applies directly to DOM — no eval.
5
+ * Handles caching (localStorage / IndexedDB) and offline fallback.
6
+ */
7
+ (function (global) {
8
+ const VERSION = "1.0.0";
9
+ const IDB_NAME = "webhanger_cache";
10
+ const IDB_STORE = "components";
11
+ const LS_PREFIX = "wh_";
12
+
13
+ // ─── XOR decrypt (mirrors bundler) ───────────────────────────────────────
14
+
15
+ function xorDecode(str, key) {
16
+ let out = "";
17
+ for (let i = 0; i < str.length; i++) {
18
+ out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function decrypt(b64, projectId, salt) {
24
+ if (!b64) return "";
25
+ const decoded = atob(b64);
26
+ return xorDecode(decoded, projectId + salt);
27
+ }
28
+
29
+ // ─── IndexedDB helpers ───────────────────────────────────────────────────
30
+
31
+ function openIDB() {
32
+ return new Promise((resolve, reject) => {
33
+ const req = indexedDB.open(IDB_NAME, 1);
34
+ req.onupgradeneeded = (e) => e.target.result.createObjectStore(IDB_STORE);
35
+ req.onsuccess = (e) => resolve(e.target.result);
36
+ req.onerror = () => reject(req.error);
37
+ });
38
+ }
39
+
40
+ async function idbGet(key) {
41
+ const db = await openIDB();
42
+ return new Promise((resolve, reject) => {
43
+ const tx = db.transaction(IDB_STORE, "readonly");
44
+ const req = tx.objectStore(IDB_STORE).get(key);
45
+ req.onsuccess = () => resolve(req.result);
46
+ req.onerror = () => reject(req.error);
47
+ });
48
+ }
49
+
50
+ async function idbSet(key, value) {
51
+ const db = await openIDB();
52
+ return new Promise((resolve, reject) => {
53
+ const tx = db.transaction(IDB_STORE, "readwrite");
54
+ const req = tx.objectStore(IDB_STORE).put(value, key);
55
+ req.onsuccess = () => resolve();
56
+ req.onerror = () => reject(req.error);
57
+ });
58
+ }
59
+
60
+ // ─── Cache layer ─────────────────────────────────────────────────────────
61
+
62
+ const SIZE_THRESHOLD = 50 * 1024;
63
+
64
+ async function cacheGet(key) {
65
+ try {
66
+ const ls = localStorage.getItem(LS_PREFIX + key);
67
+ if (ls) return ls;
68
+ } catch (_) {}
69
+ return await idbGet(key);
70
+ }
71
+
72
+ async function cacheSet(key, value) {
73
+ try {
74
+ if (value.length < SIZE_THRESHOLD) {
75
+ localStorage.setItem(LS_PREFIX + key, value);
76
+ return;
77
+ }
78
+ } catch (_) {}
79
+ await idbSet(key, value);
80
+ }
81
+
82
+ // ─── Fetch from CDN ───────────────────────────────────────────────────────
83
+
84
+ async function fetchComponent(cdnUrl, token, expires) {
85
+ const url = expires
86
+ ? `${cdnUrl}?token=${token}&expires=${expires}`
87
+ : `${cdnUrl}?token=${token}`;
88
+ const res = await fetch(url);
89
+ if (!res.ok) throw new Error(`Failed to fetch component: ${res.status}`);
90
+ return await res.text();
91
+ }
92
+
93
+ // ─── Decrypt + inject directly into DOM (no eval) ─────────────────────────
94
+
95
+ function injectComponent(encryptedPayload, projectId, targetSelector) {
96
+ const target = document.querySelector(targetSelector || "[data-wh]");
97
+ if (!target) {
98
+ console.warn("[WebHanger] No mount target found.");
99
+ return;
100
+ }
101
+
102
+ let payload;
103
+ try {
104
+ payload = JSON.parse(encryptedPayload);
105
+ } catch (_) {
106
+ console.error("[WebHanger] Invalid component payload.");
107
+ return;
108
+ }
109
+
110
+ // Decrypt CSS → inject into <head>
111
+ const css = decrypt(payload.c, projectId, "::css");
112
+ if (css) {
113
+ const style = document.createElement("style");
114
+ style.textContent = css;
115
+ document.head.appendChild(style);
116
+ }
117
+
118
+ // Decrypt HTML → inject into mount target
119
+ const html = decrypt(payload.h, projectId, "::html");
120
+ if (html) target.innerHTML = html;
121
+
122
+ // Decrypt JS → inject as <script> (no eval, browser parses it natively)
123
+ const js = decrypt(payload.j, projectId, "::js");
124
+ if (js) {
125
+ const script = document.createElement("script");
126
+ script.textContent = js;
127
+ document.head.appendChild(script);
128
+ document.head.removeChild(script);
129
+ }
130
+ }
131
+
132
+ // ─── Main loader ──────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * @param {string} cdnUrl - Full CDN URL of the component
136
+ * @param {string} projectId - Your WebHanger project ID (used as decrypt key)
137
+ * @param {string} token - HMAC signed token
138
+ * @param {number} expires - Token expiry unix timestamp (0 = never)
139
+ * @param {string} [selector] - CSS selector of mount element (default: [data-wh])
140
+ */
141
+ async function load(cdnUrl, projectId, token, expires, selector = "[data-wh]") {
142
+ if (!cdnUrl || !projectId || !token || expires === undefined || expires === null) {
143
+ console.error("[WebHanger] Missing required params: cdnUrl, projectId, token, expires");
144
+ return;
145
+ }
146
+
147
+ if (expires !== 0 && Math.floor(Date.now() / 1000) > expires) {
148
+ console.warn("[WebHanger] Token expired.");
149
+ return;
150
+ }
151
+
152
+ const cacheKey = `${cdnUrl}@${expires}`;
153
+
154
+ try {
155
+ let payload = await cacheGet(cacheKey);
156
+
157
+ if (!payload) {
158
+ payload = await fetchComponent(cdnUrl, token, expires);
159
+ await cacheSet(cacheKey, payload);
160
+ }
161
+
162
+ injectComponent(payload, projectId, selector);
163
+ } catch (err) {
164
+ console.error("[WebHanger] Load failed:", err.message);
165
+ }
166
+ }
167
+
168
+ global.WebHanger = { load, version: VERSION };
169
+
170
+ })(window);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * WebHanger Service Worker
3
+ * Caches component responses for offline use.
4
+ * Register this in your app: navigator.serviceWorker.register('/webhanger.sw.js')
5
+ */
6
+
7
+ const CACHE_NAME = "webhanger_sw_v1";
8
+
9
+ // Cache component fetch requests
10
+ self.addEventListener("fetch", (event) => {
11
+ const url = new URL(event.request.url);
12
+
13
+ // Only intercept requests to component paths
14
+ if (!url.pathname.includes("/components/")) return;
15
+
16
+ event.respondWith(
17
+ caches.open(CACHE_NAME).then(async (cache) => {
18
+ const cached = await cache.match(event.request);
19
+ if (cached) return cached;
20
+
21
+ try {
22
+ const response = await fetch(event.request);
23
+ if (response.ok) {
24
+ cache.put(event.request, response.clone());
25
+ }
26
+ return response;
27
+ } catch (_) {
28
+ // Offline and not cached
29
+ return new Response("/* [WebHanger] Component unavailable offline */", {
30
+ headers: { "Content-Type": "application/javascript" }
31
+ });
32
+ }
33
+ })
34
+ );
35
+ });
36
+
37
+ // Clean old caches on SW update
38
+ self.addEventListener("activate", (event) => {
39
+ event.waitUntil(
40
+ caches.keys().then((keys) =>
41
+ Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
42
+ )
43
+ );
44
+ });