diff --git a/hailbytes-vuln-calculator.js b/hailbytes-vuln-calculator.js
index 1d2c2aa..dedec17 100644
--- a/hailbytes-vuln-calculator.js
+++ b/hailbytes-vuln-calculator.js
@@ -550,8 +550,7 @@ const STYLES = `
// ─── Template ─────────────────────────────────────────────────────────────────
-const TMPL = document.createElement('template');
-TMPL.innerHTML = `
+const TEMPLATE_HTML = `
`;
+// Only touch the DOM when it exists. This keeps the module importable in
+// DOM-free environments (Node, SSR, Next.js server components) so the pure
+// `calculate` export can be used without a browser or DOM shim.
+const TMPL = typeof document !== 'undefined'
+ ? document.createElement('template')
+ : null;
+if (TMPL) TMPL.innerHTML = TEMPLATE_HTML;
+
// ─── Web Component ────────────────────────────────────────────────────────────
-class HailbytesVulnCalculator extends HTMLElement {
+// Fall back to a plain base class off-DOM so the class declaration itself
+// doesn't throw when `HTMLElement` is undefined. The element is only ever
+// instantiated by the browser's custom-element machinery, so the DOM methods
+// used in the constructor are never reached off-DOM.
+const ComponentBase = typeof HTMLElement !== 'undefined' ? HTMLElement : class {};
+
+class HailbytesVulnCalculator extends ComponentBase {
static get observedAttributes() { return ['theme', 'branding']; }
constructor() {
@@ -856,7 +869,10 @@ class HailbytesVulnCalculator extends HTMLElement {
}
}
-customElements.define('hailbytes-vuln-calculator', HailbytesVulnCalculator);
+// Register the custom element only in a browser context.
+if (typeof customElements !== 'undefined') {
+ customElements.define('hailbytes-vuln-calculator', HailbytesVulnCalculator);
+}
export default HailbytesVulnCalculator;
export { HailbytesVulnCalculator, calculateResources as calculate };
diff --git a/test/dom-free.test.mjs b/test/dom-free.test.mjs
new file mode 100644
index 0000000..1401c8e
--- /dev/null
+++ b/test/dom-free.test.mjs
@@ -0,0 +1,35 @@
+// Regression test: the module must be importable in a DOM-free environment
+// (Node, SSR, Next.js server components) so the pure `calculate` export works
+// without a browser or a DOM shim. NOTE: this file intentionally does NOT set
+// up any `document`/`HTMLElement`/`customElements` globals — `node --test` runs
+// each test file in its own process, so importing here exercises the real
+// off-DOM code path.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+test('module imports without a DOM and exposes calculate()', async () => {
+ assert.equal(typeof globalThis.document, 'undefined');
+ assert.equal(typeof globalThis.HTMLElement, 'undefined');
+ assert.equal(typeof globalThis.customElements, 'undefined');
+
+ const mod = await import('../hailbytes-vuln-calculator.js');
+ assert.equal(typeof mod.calculate, 'function');
+});
+
+test('calculate() runs off-DOM and returns a sizing result', async () => {
+ const { calculate } = await import('../hailbytes-vuln-calculator.js');
+
+ const result = calculate({
+ target_hosts: 1000,
+ scan_intensity: 'medium',
+ scan_frequency: 'weekly',
+ scan_window: 8,
+ scanning_tools: ['openvas'],
+ compliance_needs: [],
+ });
+
+ assert.ok(result.vm_resources.cpu_cores >= 2);
+ assert.ok(result.costs.total_monthly_aws > 0);
+ assert.ok(Array.isArray(result.recommendations));
+});