webhanger 1.0.0 → 1.0.4
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 +594 -0
- package/bin/cli.js +502 -2
- package/core/builder.js +133 -0
- package/core/registry.js +15 -6
- package/core/resolver.js +63 -0
- package/helper/accessControl.js +112 -0
- package/helper/analyzer.js +184 -0
- package/helper/breakdown.js +63 -0
- package/helper/bundler.js +29 -15
- package/helper/converter.js +190 -0
- package/helper/crypto.js +39 -0
- package/helper/dbHandler.js +15 -1
- package/helper/loadConfig.js +25 -10
- package/index.js +5 -0
- package/package.json +13 -3
package/core/registry.js
CHANGED
|
@@ -16,14 +16,23 @@ export async function deploy(config, componentDir, name, version, dependencies =
|
|
|
16
16
|
// 1. Bundle + encode
|
|
17
17
|
const bundledJs = await bundle(componentDir, projectId);
|
|
18
18
|
|
|
19
|
-
// 2.
|
|
19
|
+
// 2. Extract dependencies declared in webhanger.component.json
|
|
20
|
+
let parsedPayload = {};
|
|
21
|
+
try { parsedPayload = JSON.parse(bundledJs); } catch (_) {}
|
|
22
|
+
const resolvedDeps = dependencies.length ? dependencies : (parsedPayload.dependencies || []);
|
|
23
|
+
|
|
24
|
+
// 3. Upload
|
|
20
25
|
const storageKey = `components/${name}@${version}.js`;
|
|
21
26
|
await upload(storage, storageKey, bundledJs);
|
|
22
27
|
|
|
23
|
-
//
|
|
28
|
+
// 4. Generate CDN URLs — primary + fallbacks for multi-CDN failover
|
|
24
29
|
const cdnUrl = `${cdn.url}/${storageKey}`;
|
|
25
|
-
|
|
30
|
+
const cdnUrls = [cdnUrl];
|
|
31
|
+
if (cdn.fallbacks && cdn.fallbacks.length) {
|
|
32
|
+
cdn.fallbacks.forEach(fb => cdnUrls.push(`${fb}/${storageKey}`));
|
|
33
|
+
}
|
|
26
34
|
|
|
35
|
+
let token, expires;
|
|
27
36
|
if (customToken) {
|
|
28
37
|
expires = expiresInSeconds ? Math.floor(Date.now() / 1000) + expiresInSeconds : 0;
|
|
29
38
|
token = customToken;
|
|
@@ -31,8 +40,8 @@ export async function deploy(config, componentDir, name, version, dependencies =
|
|
|
31
40
|
({ token, expires } = signUrl(storageKey, projectId, secretKey, expiresInSeconds));
|
|
32
41
|
}
|
|
33
42
|
|
|
34
|
-
//
|
|
35
|
-
await registerComponent(db, projectId, { name, version, cdnUrl, token, expires, dependencies });
|
|
43
|
+
// 5. Register with all CDN URLs
|
|
44
|
+
await registerComponent(db, projectId, { name, version, cdnUrl, cdnUrls, token, expires, dependencies: resolvedDeps });
|
|
36
45
|
|
|
37
|
-
return { cdnUrl, token, expires };
|
|
46
|
+
return { cdnUrl, cdnUrls, token, expires, dependencies: resolvedDeps };
|
|
38
47
|
}
|
package/core/resolver.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getComponent } from "../helper/dbHandler.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a full dependency graph for a component.
|
|
5
|
+
* Returns a flat ordered array of components to load — deps first, then the component.
|
|
6
|
+
* Detects circular dependencies and throws.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} dbConfig
|
|
9
|
+
* @param {string} projectId
|
|
10
|
+
* @param {string} name
|
|
11
|
+
* @param {string} version
|
|
12
|
+
* @returns {Array<{name, version, cdnUrl, token, expires}>}
|
|
13
|
+
*/
|
|
14
|
+
export async function resolve(dbConfig, projectId, name, version) {
|
|
15
|
+
const visited = new Set();
|
|
16
|
+
const order = [];
|
|
17
|
+
|
|
18
|
+
async function walk(n, v, chain = []) {
|
|
19
|
+
const key = `${n}@${v}`;
|
|
20
|
+
|
|
21
|
+
// Circular dependency check
|
|
22
|
+
if (chain.includes(key)) {
|
|
23
|
+
throw new Error(`Circular dependency detected: ${[...chain, key].join(" → ")}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Already resolved
|
|
27
|
+
if (visited.has(key)) return;
|
|
28
|
+
visited.add(key);
|
|
29
|
+
|
|
30
|
+
const meta = await getComponent(dbConfig, projectId, n, v);
|
|
31
|
+
if (!meta) throw new Error(`Component ${key} not found in registry.`);
|
|
32
|
+
|
|
33
|
+
// Walk dependencies first (depth-first)
|
|
34
|
+
if (meta.dependencies && meta.dependencies.length) {
|
|
35
|
+
for (const dep of meta.dependencies) {
|
|
36
|
+
const [depName, depVersion] = parseDep(dep);
|
|
37
|
+
await walk(depName, depVersion, [...chain, key]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
order.push({
|
|
42
|
+
name: n,
|
|
43
|
+
version: v,
|
|
44
|
+
cdnUrl: meta.cdnUrl,
|
|
45
|
+
token: meta.token,
|
|
46
|
+
expires: meta.expires,
|
|
47
|
+
assets: meta.assets || []
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await walk(name, version);
|
|
52
|
+
return order;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses "navbar@1.2.0" → ["navbar", "1.2.0"]
|
|
57
|
+
* Parses "navbar" → ["navbar", "latest"]
|
|
58
|
+
*/
|
|
59
|
+
export function parseDep(dep) {
|
|
60
|
+
const atIndex = dep.lastIndexOf("@");
|
|
61
|
+
if (atIndex <= 0) return [dep, "latest"];
|
|
62
|
+
return [dep.slice(0, atIndex), dep.slice(atIndex + 1)];
|
|
63
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebHanger Access Control
|
|
3
|
+
* Role-based permissions stored in DB per project.
|
|
4
|
+
*
|
|
5
|
+
* Roles: owner | admin | deployer | viewer
|
|
6
|
+
* Actions: deploy | read | delete | manage_access
|
|
7
|
+
*
|
|
8
|
+
* Storage: projects/{projectId}/access/{apiKey}
|
|
9
|
+
* {
|
|
10
|
+
* role: "deployer",
|
|
11
|
+
* permissions: ["deploy", "read"],
|
|
12
|
+
* createdAt: ...,
|
|
13
|
+
* label: "CI/CD key"
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from "crypto";
|
|
18
|
+
|
|
19
|
+
// ─── Permission matrix ────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const ROLE_PERMISSIONS = {
|
|
22
|
+
owner: ["deploy", "read", "delete", "manage_access"],
|
|
23
|
+
admin: ["deploy", "read", "delete", "manage_access"],
|
|
24
|
+
deployer: ["deploy", "read"],
|
|
25
|
+
viewer: ["read"]
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ─── Key generation ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export function generateApiKey() {
|
|
31
|
+
return `wh_key_${crypto.randomBytes(24).toString("hex")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Firebase RBAC ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async function getFirestore(serviceAccountPath) {
|
|
37
|
+
const admin = (await import("firebase-admin")).default;
|
|
38
|
+
const fs = (await import("fs-extra")).default;
|
|
39
|
+
const serviceAccount = fs.readJsonSync(serviceAccountPath);
|
|
40
|
+
if (!admin.apps.length) {
|
|
41
|
+
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
|
|
42
|
+
}
|
|
43
|
+
return admin.firestore();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Grant a role to an API key for a project.
|
|
48
|
+
*/
|
|
49
|
+
export async function grantAccess(dbConfig, projectId, apiKey, role, label = "") {
|
|
50
|
+
if (!ROLE_PERMISSIONS[role]) throw new Error(`Unknown role: ${role}. Valid: ${Object.keys(ROLE_PERMISSIONS).join(", ")}`);
|
|
51
|
+
|
|
52
|
+
if (dbConfig.provider === "firebase") {
|
|
53
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
54
|
+
await db.collection("projects").doc(projectId)
|
|
55
|
+
.collection("access").doc(apiKey)
|
|
56
|
+
.set({
|
|
57
|
+
role,
|
|
58
|
+
permissions: ROLE_PERMISSIONS[role],
|
|
59
|
+
label,
|
|
60
|
+
createdAt: new Date().toISOString()
|
|
61
|
+
});
|
|
62
|
+
return { apiKey, role, permissions: ROLE_PERMISSIONS[role] };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Revoke access for an API key.
|
|
69
|
+
*/
|
|
70
|
+
export async function revokeAccess(dbConfig, projectId, apiKey) {
|
|
71
|
+
if (dbConfig.provider === "firebase") {
|
|
72
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
73
|
+
await db.collection("projects").doc(projectId)
|
|
74
|
+
.collection("access").doc(apiKey).delete();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if an API key has permission to perform an action.
|
|
82
|
+
* Returns the access record or throws if unauthorized.
|
|
83
|
+
*/
|
|
84
|
+
export async function checkPermission(dbConfig, projectId, apiKey, action) {
|
|
85
|
+
if (dbConfig.provider === "firebase") {
|
|
86
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
87
|
+
const snap = await db.collection("projects").doc(projectId)
|
|
88
|
+
.collection("access").doc(apiKey).get();
|
|
89
|
+
|
|
90
|
+
if (!snap.exists) throw new Error("Unauthorized: API key not found.");
|
|
91
|
+
|
|
92
|
+
const access = snap.data();
|
|
93
|
+
if (!access.permissions.includes(action)) {
|
|
94
|
+
throw new Error(`Forbidden: role "${access.role}" cannot perform "${action}".`);
|
|
95
|
+
}
|
|
96
|
+
return access;
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List all access entries for a project.
|
|
103
|
+
*/
|
|
104
|
+
export async function listAccess(dbConfig, projectId) {
|
|
105
|
+
if (dbConfig.provider === "firebase") {
|
|
106
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
107
|
+
const snap = await db.collection("projects").doc(projectId)
|
|
108
|
+
.collection("access").get();
|
|
109
|
+
return snap.docs.map(d => ({ apiKey: d.id, ...d.data() }));
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
// Known CDN mappings for popular npm packages
|
|
5
|
+
const CDN_MAP = {
|
|
6
|
+
// CSS Frameworks
|
|
7
|
+
"tailwindcss": { type: "script", url: "https://cdn.tailwindcss.com" },
|
|
8
|
+
"bootstrap": { type: "style", url: "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css" },
|
|
9
|
+
"bootstrap/dist/js/bootstrap.bundle.min.js": { type: "script", url: "https://cdn.jsdelivr.net/npm/bootstrap@5/dist/js/bootstrap.bundle.min.js" },
|
|
10
|
+
"@mui/material": { type: "style", url: "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" },
|
|
11
|
+
"bulma": { type: "style", url: "https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" },
|
|
12
|
+
"animate.css": { type: "style", url: "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" },
|
|
13
|
+
|
|
14
|
+
// Animation / 3D
|
|
15
|
+
"gsap": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js" },
|
|
16
|
+
"three": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" },
|
|
17
|
+
"animejs": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js" },
|
|
18
|
+
"lottie-web": { type: "script", url: "https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js" },
|
|
19
|
+
"framer-motion": null, // SSR only, skip
|
|
20
|
+
|
|
21
|
+
// Utility
|
|
22
|
+
"alpinejs": { type: "script", url: "https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js", defer: true },
|
|
23
|
+
"htmx.org": { type: "script", url: "https://unpkg.com/htmx.org@1.9.10" },
|
|
24
|
+
"axios": { type: "script", url: "https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js" },
|
|
25
|
+
"lodash": { type: "script", url: "https://cdn.jsdelivr.net/npm/lodash@4/lodash.min.js" },
|
|
26
|
+
"dayjs": { type: "script", url: "https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js" },
|
|
27
|
+
"chart.js": { type: "script", url: "https://cdn.jsdelivr.net/npm/chart.js" },
|
|
28
|
+
"d3": { type: "script", url: "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js" },
|
|
29
|
+
"swiper": { type: "style", url: "https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" },
|
|
30
|
+
|
|
31
|
+
// Fonts
|
|
32
|
+
"@fontsource": { type: "style", url: "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Framework detection patterns
|
|
36
|
+
const FRAMEWORK_PATTERNS = {
|
|
37
|
+
react: ["import React", "from 'react'", "from \"react\"", "jsx", ".tsx", ".jsx"],
|
|
38
|
+
vue: ["defineComponent", "from 'vue'", "from \"vue\"", "<template>", ".vue"],
|
|
39
|
+
svelte: ["<script>", "<style>", ".svelte", "export let"],
|
|
40
|
+
next: ["from 'next'", "from \"next\"", "getServerSideProps", "getStaticProps"],
|
|
41
|
+
nuxt: ["defineNuxtComponent", "useNuxtApp", "from '#app'"],
|
|
42
|
+
astro: [".astro", "Astro.props"],
|
|
43
|
+
angular: ["@Component", "@NgModule", "from '@angular'"],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Detects framework from file contents + extensions.
|
|
48
|
+
*/
|
|
49
|
+
function detectFramework(files, contents) {
|
|
50
|
+
for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
|
|
51
|
+
for (const pattern of patterns) {
|
|
52
|
+
if (files.some(f => f.includes(pattern.replace(".", "")))) return framework;
|
|
53
|
+
if (contents.some(c => c.includes(pattern))) return framework;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return "vanilla";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Scans import statements and package.json to find used npm packages.
|
|
61
|
+
*/
|
|
62
|
+
function extractImports(contents) {
|
|
63
|
+
const imports = new Set();
|
|
64
|
+
const importRegex = /from\s+['"]([^'"./][^'"]*)['"]/g;
|
|
65
|
+
const requireRegex = /require\s*\(\s*['"]([^'"./][^'"]*)['"]\s*\)/g;
|
|
66
|
+
|
|
67
|
+
for (const content of contents) {
|
|
68
|
+
let match;
|
|
69
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
70
|
+
imports.add(match[1].split("/")[0]); // get root package name
|
|
71
|
+
}
|
|
72
|
+
while ((match = requireRegex.exec(content)) !== null) {
|
|
73
|
+
imports.add(match[1].split("/")[0]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...imports];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Detects styling approach from file contents + extensions.
|
|
81
|
+
*/
|
|
82
|
+
function detectStyling(files, contents) {
|
|
83
|
+
const styling = [];
|
|
84
|
+
|
|
85
|
+
if (contents.some(c => c.includes("tailwind") || c.includes("className=\"") || c.includes("class=\""))) {
|
|
86
|
+
if (contents.some(c => c.includes("tw-") || c.includes("bg-") || c.includes("text-") || c.includes("flex "))) {
|
|
87
|
+
styling.push("tailwind");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (files.some(f => f.endsWith(".css") || f.endsWith(".scss"))) styling.push("css");
|
|
91
|
+
if (contents.some(c => c.includes("styled-components") || c.includes("css`"))) styling.push("styled-components");
|
|
92
|
+
if (contents.some(c => c.includes("@emotion"))) styling.push("emotion");
|
|
93
|
+
if (contents.some(c => c.includes("module.css") || c.includes(".module."))) styling.push("css-modules");
|
|
94
|
+
|
|
95
|
+
return styling.length ? styling : ["css"];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Maps detected npm packages to CDN URLs.
|
|
100
|
+
*/
|
|
101
|
+
function resolveCdnAssets(imports, styling) {
|
|
102
|
+
const assets = [];
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
|
|
105
|
+
// Add styling CDN assets first
|
|
106
|
+
if (styling.includes("tailwind")) {
|
|
107
|
+
assets.push(CDN_MAP["tailwindcss"]);
|
|
108
|
+
seen.add("tailwindcss");
|
|
109
|
+
}
|
|
110
|
+
if (styling.includes("bootstrap")) {
|
|
111
|
+
assets.push(CDN_MAP["bootstrap"]);
|
|
112
|
+
seen.add("bootstrap");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Map imports to CDN
|
|
116
|
+
for (const pkg of imports) {
|
|
117
|
+
if (seen.has(pkg)) continue;
|
|
118
|
+
if (CDN_MAP[pkg] && CDN_MAP[pkg] !== null) {
|
|
119
|
+
assets.push(CDN_MAP[pkg]);
|
|
120
|
+
seen.add(pkg);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return assets;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Main analyzer — scans a component directory and returns:
|
|
129
|
+
* { framework, styling, imports, assets, meta }
|
|
130
|
+
*/
|
|
131
|
+
export async function analyzeComponent(componentDir) {
|
|
132
|
+
const files = await fs.readdir(componentDir);
|
|
133
|
+
const contents = [];
|
|
134
|
+
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
if (file === "webhanger.component.json") continue;
|
|
137
|
+
const filePath = path.join(componentDir, file);
|
|
138
|
+
const stat = await fs.stat(filePath);
|
|
139
|
+
if (stat.isFile()) {
|
|
140
|
+
try {
|
|
141
|
+
contents.push(await fs.readFile(filePath, "utf-8"));
|
|
142
|
+
} catch (_) {}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Check if project has package.json for deeper dep scanning
|
|
147
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
148
|
+
let projectDeps = [];
|
|
149
|
+
if (await fs.pathExists(pkgPath)) {
|
|
150
|
+
const pkg = await fs.readJson(pkgPath);
|
|
151
|
+
projectDeps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const framework = detectFramework(files, contents);
|
|
155
|
+
const styling = detectStyling(files, contents);
|
|
156
|
+
const imports = extractImports(contents);
|
|
157
|
+
const allDeps = [...new Set([...imports, ...projectDeps.filter(d => imports.includes(d))])];
|
|
158
|
+
const assets = resolveCdnAssets(allDeps, styling);
|
|
159
|
+
|
|
160
|
+
return { framework, styling, imports: allDeps, assets };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Auto-generates webhanger.component.json if it doesn't exist.
|
|
165
|
+
* If it exists, merges new assets without overwriting manual ones.
|
|
166
|
+
*/
|
|
167
|
+
export async function autoGenerateComponentMeta(componentDir) {
|
|
168
|
+
const metaPath = path.join(componentDir, "webhanger.component.json");
|
|
169
|
+
const analysis = await analyzeComponent(componentDir);
|
|
170
|
+
|
|
171
|
+
let existing = { assets: [] };
|
|
172
|
+
if (await fs.pathExists(metaPath)) {
|
|
173
|
+
existing = await fs.readJson(metaPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Merge — don't duplicate URLs
|
|
177
|
+
const existingUrls = new Set(existing.assets.map(a => a.url));
|
|
178
|
+
const newAssets = analysis.assets.filter(a => !existingUrls.has(a.url));
|
|
179
|
+
const merged = { ...existing, assets: [...existing.assets, ...newAssets] };
|
|
180
|
+
|
|
181
|
+
await fs.writeJson(metaPath, merged, { spaces: 2 });
|
|
182
|
+
|
|
183
|
+
return { analysis, meta: merged };
|
|
184
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Breakdown Engine
|
|
6
|
+
* Detects a single HTML file with embedded <style> and <script> tags.
|
|
7
|
+
* Extracts them into separate index.html, style.css, script.js files.
|
|
8
|
+
* Works on component folders OR standalone HTML files.
|
|
9
|
+
*/
|
|
10
|
+
export async function breakdown(componentDir) {
|
|
11
|
+
const files = await fs.readdir(componentDir);
|
|
12
|
+
const htmlFiles = files.filter(f => f.endsWith(".html"));
|
|
13
|
+
const hasCSS = files.some(f => f.endsWith(".css"));
|
|
14
|
+
const hasJS = files.some(f => f.endsWith(".js") && f !== "webhanger.component.json");
|
|
15
|
+
|
|
16
|
+
// Only run if there's exactly one HTML file and no separate css/js yet
|
|
17
|
+
if (htmlFiles.length !== 1 || (hasCSS && hasJS)) return false;
|
|
18
|
+
|
|
19
|
+
const htmlPath = path.join(componentDir, htmlFiles[0]);
|
|
20
|
+
const raw = await fs.readFile(htmlPath, "utf-8");
|
|
21
|
+
|
|
22
|
+
// Extract all <style> blocks
|
|
23
|
+
const styleMatches = [...raw.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)];
|
|
24
|
+
const css = styleMatches.map(m => m[1].trim()).join("\n\n");
|
|
25
|
+
|
|
26
|
+
// Extract all <script> blocks (non-src)
|
|
27
|
+
const scriptMatches = [...raw.matchAll(/<script(?![^>]*src)[^>]*>([\s\S]*?)<\/script>/gi)];
|
|
28
|
+
const js = scriptMatches.map(m => m[1].trim()).join("\n\n");
|
|
29
|
+
|
|
30
|
+
// Strip <style> and <script> from HTML, clean up blank lines
|
|
31
|
+
let html = raw
|
|
32
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
33
|
+
.replace(/<script(?![^>]*src)[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
34
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
35
|
+
.trim();
|
|
36
|
+
|
|
37
|
+
// Strip outer <html><head><body> wrapper if present — keep inner content only
|
|
38
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
39
|
+
if (bodyMatch) html = bodyMatch[1].trim();
|
|
40
|
+
|
|
41
|
+
const changed = css || js;
|
|
42
|
+
if (!changed) return false;
|
|
43
|
+
|
|
44
|
+
// Write extracted files
|
|
45
|
+
await fs.writeFile(path.join(componentDir, "index.html"), html, "utf-8");
|
|
46
|
+
if (css) await fs.writeFile(path.join(componentDir, "style.css"), css, "utf-8");
|
|
47
|
+
if (js) await fs.writeFile(path.join(componentDir, "script.js"), js, "utf-8");
|
|
48
|
+
|
|
49
|
+
// Rename original if it wasn't index.html
|
|
50
|
+
if (htmlFiles[0] !== "index.html") {
|
|
51
|
+
await fs.remove(htmlPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
extracted: {
|
|
56
|
+
html: !!html,
|
|
57
|
+
css: !!css,
|
|
58
|
+
js: !!js
|
|
59
|
+
},
|
|
60
|
+
cssLines: css.split("\n").length,
|
|
61
|
+
jsLines: js.split("\n").length
|
|
62
|
+
};
|
|
63
|
+
}
|
package/helper/bundler.js
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { autoGenerateComponentMeta } from "./analyzer.js";
|
|
4
|
+
import { encrypt, integrityHash } from "./crypto.js";
|
|
5
|
+
import { breakdown } from "./breakdown.js";
|
|
3
6
|
|
|
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
7
|
|
|
12
8
|
/**
|
|
13
9
|
* Bundles html+css+js into an encrypted JSON payload.
|
|
10
|
+
* Reads webhanger.component.json for CDN asset declarations.
|
|
14
11
|
* 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
12
|
*/
|
|
17
13
|
export async function bundle(componentDir, projectId) {
|
|
14
|
+
// Auto-breakdown: extract <style>/<script> from single HTML file if needed
|
|
15
|
+
const broke = await breakdown(componentDir);
|
|
16
|
+
if (broke) {
|
|
17
|
+
console.log(` ✓ Breakdown: extracted CSS (${broke.cssLines} lines) + JS (${broke.jsLines} lines) from single file`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Auto-detect + generate webhanger.component.json before bundling
|
|
21
|
+
const { analysis } = await autoGenerateComponentMeta(componentDir);
|
|
22
|
+
console.log(` ✓ Framework detected: ${analysis.framework}`);
|
|
23
|
+
console.log(` ✓ Styling: ${analysis.styling.join(", ")}`);
|
|
24
|
+
if (analysis.assets.length) {
|
|
25
|
+
console.log(` ✓ CDN assets resolved: ${analysis.assets.map(a => a.url).join(", ")}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
const files = await fs.readdir(componentDir);
|
|
19
29
|
|
|
20
30
|
let html = "", css = "", js = "";
|
|
31
|
+
let meta = { assets: [] };
|
|
21
32
|
|
|
22
33
|
for (const file of files) {
|
|
23
34
|
const filePath = path.join(componentDir, file);
|
|
@@ -27,21 +38,24 @@ export async function bundle(componentDir, projectId) {
|
|
|
27
38
|
if (ext === ".html") html = content.trim();
|
|
28
39
|
else if (ext === ".css") css = content.trim();
|
|
29
40
|
else if (ext === ".js") js = content.trim();
|
|
41
|
+
else if (file === "webhanger.component.json") meta = JSON.parse(content);
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
if (!html && !js) {
|
|
33
45
|
throw new Error("Component must have at least an .html or .js file.");
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
content ? Buffer.from(xorEncode(content, projectId + salt)).toString("base64") : "";
|
|
48
|
+
// AES-256-GCM encrypt each chunk with per-chunk salt
|
|
49
|
+
const encryptChunk = (content, salt) => encrypt(content, projectId, salt);
|
|
39
50
|
|
|
40
51
|
const payload = {
|
|
41
|
-
v:
|
|
42
|
-
h:
|
|
43
|
-
c:
|
|
44
|
-
j:
|
|
52
|
+
v: 2, // version 2 = AES encrypted
|
|
53
|
+
h: encryptChunk(html, "::html"),
|
|
54
|
+
c: encryptChunk(css, "::css"),
|
|
55
|
+
j: encryptChunk(js, "::js"),
|
|
56
|
+
assets: meta.assets || [],
|
|
57
|
+
dependencies: meta.dependencies || [],
|
|
58
|
+
integrity: integrityHash(html + css + js) // tamper detection
|
|
45
59
|
};
|
|
46
60
|
|
|
47
61
|
return JSON.stringify(payload);
|