@ionify/ionify 0.1.2 → 0.1.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.
@@ -12,6 +12,29 @@ const ERROR_URL = "/__ionify_hmr/error";
12
12
  const log = (...args) => console.log("[Ionify HMR]", ...args);
13
13
  const warn = (...args) => console.warn("[Ionify HMR]", ...args);
14
14
 
15
+ // Surface runtime errors even when they don't occur during an HMR import().
16
+ // This catches errors thrown during module evaluation and async rejections.
17
+ globalThis.addEventListener?.("error", (event) => {
18
+ try {
19
+ const message = event?.message || "Runtime error";
20
+ const details = event?.error?.stack || event?.error?.message;
21
+ showErrorOverlay(message, details);
22
+ } catch {
23
+ // ignore
24
+ }
25
+ });
26
+
27
+ globalThis.addEventListener?.("unhandledrejection", (event) => {
28
+ try {
29
+ const reason = event?.reason;
30
+ const message = reason instanceof Error ? reason.message : "Unhandled promise rejection";
31
+ const details = reason instanceof Error ? reason.stack : String(reason ?? "");
32
+ showErrorOverlay(message, details);
33
+ } catch {
34
+ // ignore
35
+ }
36
+ });
37
+
15
38
  // Establish SSE channel used to notify about pending graph diffs.
16
39
  const source = new EventSource(SSE_URL);
17
40
 
@@ -22,14 +45,15 @@ source.addEventListener("ready", () => {
22
45
  source.addEventListener("error", (e) => {
23
46
  warn("SSE error", e);
24
47
  // Show overlay if server streamed a structured error payload.
25
- if (e?.data) {
48
+ const data = e && typeof e === "object" && "data" in e ? e.data : undefined;
49
+ if (data) {
26
50
  try {
27
- const payload = JSON.parse(e.data);
51
+ const payload = JSON.parse(data);
28
52
  if (payload?.message) {
29
53
  showErrorOverlay(payload.message, payload.id ? `Update ${payload.id}` : undefined);
30
54
  }
31
55
  } catch {
32
- showErrorOverlay(String(e.data || "HMR connection error"));
56
+ showErrorOverlay(String(data || "HMR connection error"));
33
57
  }
34
58
  }
35
59
  });
@@ -119,6 +143,18 @@ async function applyUpdate(payload) {
119
143
  return;
120
144
  }
121
145
  }
146
+
147
+ // If React Refresh runtime is present, trigger it after importing updated modules.
148
+ try {
149
+ const refreshRuntime = globalThis.__IONIFY_REACT_REFRESH__;
150
+ if (refreshRuntime?.performReactRefresh) {
151
+ refreshRuntime.performReactRefresh();
152
+ log("react refresh performed");
153
+ }
154
+ } catch (err) {
155
+ warn("React Refresh failed", err);
156
+ }
157
+
122
158
  log(`update ${payload?.id ?? "unknown"} applied`);
123
159
  clearErrorOverlay();
124
160
  }
@@ -1,42 +1,157 @@
1
- // Basic DOM overlay used to surface build/transform errors during HMR.
2
- const OVERLAY_ID = "ionify-error-overlay";
1
+ // Basic DOM overlay used to surface build/transform errors (and warnings) during HMR.
2
+ const ERROR_OVERLAY_ID = "ionify-error-overlay";
3
+ const WARNING_OVERLAY_ID = "ionify-warning-overlay";
3
4
 
4
- function ensureOverlay() {
5
- let overlay = document.getElementById(OVERLAY_ID);
5
+ function ensureOverlay(id) {
6
+ let overlay = document.getElementById(id);
6
7
  if (!overlay) {
7
8
  overlay = document.createElement("div");
8
- overlay.id = OVERLAY_ID;
9
- overlay.style.position = "fixed";
10
- overlay.style.inset = "0";
11
- overlay.style.background = "rgba(0,0,0,0.8)";
12
- overlay.style.color = "#f87171";
13
- overlay.style.fontFamily = "Menlo, Consolas, monospace";
14
- overlay.style.fontSize = "14px";
15
- overlay.style.padding = "32px";
16
- overlay.style.zIndex = "2147483647";
17
- overlay.style.overflowY = "auto";
18
- overlay.style.whiteSpace = "pre-wrap";
9
+ overlay.id = id;
19
10
  document.body.appendChild(overlay);
20
11
  }
21
12
  return overlay;
22
13
  }
23
14
 
15
+ function buildOverlayContent({
16
+ title,
17
+ message,
18
+ details,
19
+ onClose,
20
+ accentColor,
21
+ }) {
22
+ const root = document.createElement("div");
23
+ root.style.display = "flex";
24
+ root.style.flexDirection = "column";
25
+ root.style.gap = "12px";
26
+
27
+ const headerRow = document.createElement("div");
28
+ headerRow.style.display = "flex";
29
+ headerRow.style.alignItems = "flex-start";
30
+ headerRow.style.justifyContent = "space-between";
31
+ headerRow.style.gap = "12px";
32
+
33
+ const header = document.createElement("div");
34
+ header.style.fontWeight = "600";
35
+ header.style.fontSize = "16px";
36
+ header.textContent = title;
37
+
38
+ const close = document.createElement("button");
39
+ close.type = "button";
40
+ close.setAttribute("aria-label", "Close overlay");
41
+ close.textContent = "×";
42
+ close.style.border = "1px solid rgba(255,255,255,0.25)";
43
+ close.style.background = "transparent";
44
+ close.style.color = "inherit";
45
+ close.style.borderRadius = "8px";
46
+ close.style.width = "32px";
47
+ close.style.height = "32px";
48
+ close.style.cursor = "pointer";
49
+ close.style.fontSize = "20px";
50
+ close.style.lineHeight = "28px";
51
+ close.style.padding = "0";
52
+ close.onclick = onClose;
53
+
54
+ headerRow.appendChild(header);
55
+ headerRow.appendChild(close);
56
+
57
+ const body = document.createElement("div");
58
+ body.textContent = message ?? "";
59
+
60
+ root.appendChild(headerRow);
61
+ root.appendChild(body);
62
+
63
+ if (details) {
64
+ const pre = document.createElement("pre");
65
+ pre.style.margin = "0";
66
+ pre.style.whiteSpace = "pre-wrap";
67
+ pre.style.color = accentColor;
68
+ pre.textContent = details;
69
+ root.appendChild(pre);
70
+ }
71
+
72
+ return root;
73
+ }
74
+
24
75
  export function showErrorOverlay(message, details) {
25
76
  if (typeof document === "undefined") return;
26
- const overlay = ensureOverlay();
27
- const header = "Ionify Build Error";
28
- overlay.innerHTML = `
29
- <div style="font-weight:600;font-size:16px;margin-bottom:16px;">
30
- ${header}
31
- </div>
32
- <div>${message ?? "Unknown error"}</div>
33
- ${details ? `<pre style="margin-top:16px;color:#fca5a5;">${details}</pre>` : ""}
34
- `;
77
+ const overlay = ensureOverlay(ERROR_OVERLAY_ID);
78
+
79
+ overlay.style.position = "fixed";
80
+ overlay.style.inset = "0";
81
+ overlay.style.background = "rgba(0,0,0,0.86)";
82
+ overlay.style.color = "#f87171";
83
+ overlay.style.fontFamily = "Menlo, Consolas, monospace";
84
+ overlay.style.fontSize = "14px";
85
+ overlay.style.padding = "32px";
86
+ overlay.style.zIndex = "2147483647";
87
+ overlay.style.overflowY = "auto";
88
+ overlay.style.whiteSpace = "pre-wrap";
89
+
90
+ overlay.replaceChildren(
91
+ buildOverlayContent({
92
+ title: "Ionify Build Error",
93
+ message: message ?? "Unknown error",
94
+ details,
95
+ onClose: clearErrorOverlay,
96
+ accentColor: "#fca5a5",
97
+ })
98
+ );
35
99
  }
36
100
 
37
101
  export function clearErrorOverlay() {
38
102
  if (typeof document === "undefined") return;
39
- const overlay = document.getElementById(OVERLAY_ID);
103
+ const overlay = document.getElementById(ERROR_OVERLAY_ID);
104
+ if (overlay && overlay.parentElement) {
105
+ overlay.parentElement.removeChild(overlay);
106
+ }
107
+ }
108
+
109
+ export function showWarningOverlay(message, details) {
110
+ if (typeof document === "undefined") return;
111
+ const overlay = ensureOverlay(WARNING_OVERLAY_ID);
112
+
113
+ // Full-screen, transparent backdrop with a top-center panel.
114
+ // Keep the app interactive by letting only the panel receive pointer events.
115
+ overlay.style.position = "fixed";
116
+ overlay.style.inset = "0";
117
+ overlay.style.background = "rgba(0,0,0,0.35)";
118
+ overlay.style.zIndex = "2147483646";
119
+ overlay.style.pointerEvents = "none";
120
+
121
+ const panel = document.createElement("div");
122
+ panel.style.position = "absolute";
123
+ panel.style.top = "16px";
124
+ panel.style.left = "50%";
125
+ panel.style.transform = "translateX(-50%)";
126
+ panel.style.maxWidth = "min(760px, calc(100vw - 32px))";
127
+ panel.style.width = "fit-content";
128
+ panel.style.background = "rgba(17,24,39,0.92)";
129
+ panel.style.color = "#fbbf24";
130
+ panel.style.fontFamily = "Menlo, Consolas, monospace";
131
+ panel.style.fontSize = "13px";
132
+ panel.style.padding = "16px";
133
+ panel.style.border = "1px solid rgba(251,191,36,0.45)";
134
+ panel.style.borderRadius = "12px";
135
+ panel.style.boxShadow = "0 10px 30px rgba(0,0,0,0.35)";
136
+ panel.style.overflow = "hidden";
137
+ panel.style.pointerEvents = "auto";
138
+
139
+ panel.appendChild(
140
+ buildOverlayContent({
141
+ title: "Ionify Warning",
142
+ message: message ?? "",
143
+ details,
144
+ onClose: clearWarningOverlay,
145
+ accentColor: "#fde68a",
146
+ })
147
+ );
148
+
149
+ overlay.replaceChildren(panel);
150
+ }
151
+
152
+ export function clearWarningOverlay() {
153
+ if (typeof document === "undefined") return;
154
+ const overlay = document.getElementById(WARNING_OVERLAY_ID);
40
155
  if (overlay && overlay.parentElement) {
41
156
  overlay.parentElement.removeChild(overlay);
42
157
  }
@@ -4,6 +4,31 @@ let installed = false;
4
4
  const moduleInfo = new Map();
5
5
  const warnedClassModules = new Set();
6
6
 
7
+ export function normalizeRefreshModuleId(url) {
8
+ if (typeof url !== "string") return "";
9
+ if (!url.includes("?") && !url.includes("#")) return url;
10
+
11
+ // Absolute URL: keep origin + pathname + hash, drop search.
12
+ try {
13
+ const parsed = new URL(url);
14
+ return parsed.origin + parsed.pathname + parsed.hash;
15
+ } catch {
16
+ // Fall through for non-absolute URLs.
17
+ }
18
+
19
+ // Relative URL: keep pathname + hash, drop search.
20
+ try {
21
+ const parsed = new URL(url, "http://ionify.invalid");
22
+ return parsed.pathname + parsed.hash;
23
+ } catch {
24
+ // Final fallback: conservative string manipulation.
25
+ }
26
+
27
+ const [beforeHash, hash = ""] = url.split("#", 2);
28
+ const [pathPart] = beforeHash.split("?", 2);
29
+ return pathPart + (hash ? `#${hash}` : "");
30
+ }
31
+
7
32
  function ensureRuntime() {
8
33
  if (installed) return RefreshRuntime;
9
34
  RefreshRuntime.injectIntoGlobalHook(window);
@@ -14,26 +39,55 @@ function ensureRuntime() {
14
39
  return RefreshRuntime;
15
40
  }
16
41
 
42
+ // Ensure the global hook is installed as early as possible (before React modules execute).
43
+ // Guarded so Node-side unit tests can import helpers from this file.
44
+ if (typeof window !== "undefined") {
45
+ try {
46
+ ensureRuntime();
47
+ } catch {
48
+ // Runtime injection is best-effort; transform-side warnings handle failures.
49
+ }
50
+ }
51
+
17
52
  export function setupReactRefresh(importMetaHot, moduleId) {
18
53
  if (!importMetaHot) return null;
19
54
  const runtime = ensureRuntime();
55
+ const stableModuleId = normalizeRefreshModuleId(moduleId);
20
56
 
21
57
  // Track metadata for this module so we can make refresh decisions later.
22
58
  const record = {
23
59
  hasReactExport: false,
24
60
  hasClassComponent: false,
25
61
  };
26
- moduleInfo.set(moduleId, record);
62
+ moduleInfo.set(stableModuleId, record);
27
63
 
28
64
  const prevReg = window.$RefreshReg$;
29
65
  const prevSig = window.$RefreshSig$;
30
66
 
31
67
  window.$RefreshReg$ = (type, id) => {
32
- runtime.register(type, moduleId + " " + id);
68
+ runtime.register(type, stableModuleId + " " + id);
33
69
  if (type) {
34
70
  record.hasReactExport = true;
35
71
  if (type.prototype && type.prototype.isReactComponent) {
36
72
  record.hasClassComponent = true;
73
+
74
+ // Show the warning even if a refresh never runs (e.g. first load),
75
+ // but dedupe per stable module id.
76
+ if (!warnedClassModules.has(stableModuleId)) {
77
+ const warning = `[Ionify] React Fast Refresh cannot preserve state for class components (module: ${stableModuleId}). State will reset after edits.`;
78
+ console.warn(warning);
79
+
80
+ if (typeof document !== "undefined") {
81
+ import("/__ionify_overlay.js")
82
+ .then((mod) => {
83
+ if (typeof mod?.showWarningOverlay === "function") {
84
+ mod.showWarningOverlay(warning, stableModuleId);
85
+ }
86
+ })
87
+ .catch(() => {});
88
+ }
89
+ warnedClassModules.add(stableModuleId);
90
+ }
37
91
  }
38
92
  }
39
93
  };
@@ -45,17 +99,11 @@ export function setupReactRefresh(importMetaHot, moduleId) {
45
99
  };
46
100
 
47
101
  const dispose = () => {
48
- moduleInfo.delete(moduleId);
102
+ moduleInfo.delete(stableModuleId);
49
103
  };
50
104
 
51
105
  const refresh = () => {
52
106
  if (!record.hasReactExport) return false;
53
- if (record.hasClassComponent && !warnedClassModules.has(moduleId)) {
54
- console.warn(
55
- `[Ionify] React Fast Refresh cannot preserve state for class components (module: ${moduleId}). State will reset after edits.`
56
- );
57
- warnedClassModules.add(moduleId);
58
- }
59
107
  queueMicrotask(() => {
60
108
  runtime.performReactRefresh();
61
109
  });
package/dist/index.d.cts CHANGED
@@ -2,7 +2,21 @@ interface IonifyResolveConfig {
2
2
  baseUrl?: string;
3
3
  paths?: Record<string, string[]>;
4
4
  alias?: Record<string, string>;
5
+ /**
6
+ * File extensions to try when resolving imports.
7
+ * @default ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json']
8
+ */
5
9
  extensions?: string[];
10
+ /**
11
+ * Conditions to use when resolving package.json "exports" field.
12
+ * @default ['import', 'module', 'browser', 'default']
13
+ */
14
+ conditions?: string[];
15
+ /**
16
+ * Fields to check in package.json for entry point resolution.
17
+ * @default ['module', 'jsnext:main', 'jsnext', 'main']
18
+ */
19
+ mainFields?: string[];
6
20
  }
7
21
  interface IonifyServerConfig {
8
22
  port?: number;
@@ -51,7 +65,19 @@ interface IonifyScopeHoistConfig {
51
65
  combineVariables?: boolean;
52
66
  }
53
67
  interface IonifyConfig {
68
+ /**
69
+ * Project root directory. All paths are resolved relative to root.
70
+ * Affects: module resolution, watcher, public URLs, CAS location.
71
+ * @default process.cwd()
72
+ */
54
73
  root?: string;
74
+ /**
75
+ * Entry point(s) for the application (relative to root or project-relative with leading '/').
76
+ * Accepts a single entry or multiple entries.
77
+ * @example '/src/main.tsx'
78
+ * @example ['src/main.tsx', 'src/admin.tsx']
79
+ */
80
+ entry?: string | string[];
55
81
  base?: string;
56
82
  mode?: string;
57
83
  /** Runtime-resolved minifier selection ('auto' by default). */
@@ -74,11 +100,56 @@ interface IonifyConfig {
74
100
  build?: IonifyBuildConfig;
75
101
  css?: IonifyCSSConfig;
76
102
  optimizeDeps?: {
103
+ /**
104
+ * Dependencies to pre-optimize on server start.
105
+ * Useful for ensuring common dependencies are bundled before first request.
106
+ * @example ['react', 'react-dom', 'lodash']
107
+ */
77
108
  include?: string[];
78
109
  exclude?: string[];
110
+ /**
111
+ * Generate sourcemaps for optimized dependencies.
112
+ * Disabled by default to speed up dependency optimization.
113
+ */
114
+ sourcemap?: boolean;
115
+ /**
116
+ * Bundle ESM dependencies instead of serving them as-is with proxies.
117
+ * When `true` (default), ESM deps are bundled into single self-contained
118
+ * files with synthesized named exports, eliminating request waterfalls.
119
+ * Set to `false` for debugging (to inspect original package source).
120
+ * @default true
121
+ */
122
+ bundleEsm?: boolean;
123
+ /**
124
+ * Build a framework "vendor preloader" module in dev to reduce cold-start waterfalls.
125
+ * - `'auto'` (default): detect framework deps from package.json and generate `vendor.<depsHash>.js`
126
+ * - `string[]`: explicit vendor specifiers (e.g. ['react','react-dom/client'])
127
+ * - `false`: disable vendor preloading
128
+ */
129
+ vendor?: "auto" | string[] | false;
130
+ /**
131
+ * @deprecated esbuildOptions are not supported in Ionify.
132
+ * Ionify uses native Rust optimizer. This option is ignored with a warning.
133
+ */
134
+ esbuildOptions?: Record<string, unknown>;
79
135
  };
80
136
  plugins?: any[];
137
+ /**
138
+ * Define global constant replacements.
139
+ * Values are JSON-stringified and replaced at compile time using AST transformation.
140
+ * @example
141
+ * define: {
142
+ * __APP_VERSION__: JSON.stringify('1.0.0'),
143
+ * __API_URL__: JSON.stringify('https://api.example.com'),
144
+ * 'process.env.NODE_ENV': JSON.stringify('production')
145
+ * }
146
+ */
81
147
  define?: Record<string, any>;
148
+ /**
149
+ * Environment variables to expose to the client.
150
+ * All variables starting with VITE_ or IONIFY_ are automatically exposed as import.meta.env.*
151
+ */
152
+ envPrefix?: string | string[];
82
153
  }
83
154
 
84
155
  interface BuildChunkAsset {
package/dist/index.d.ts CHANGED
@@ -2,7 +2,21 @@ interface IonifyResolveConfig {
2
2
  baseUrl?: string;
3
3
  paths?: Record<string, string[]>;
4
4
  alias?: Record<string, string>;
5
+ /**
6
+ * File extensions to try when resolving imports.
7
+ * @default ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json']
8
+ */
5
9
  extensions?: string[];
10
+ /**
11
+ * Conditions to use when resolving package.json "exports" field.
12
+ * @default ['import', 'module', 'browser', 'default']
13
+ */
14
+ conditions?: string[];
15
+ /**
16
+ * Fields to check in package.json for entry point resolution.
17
+ * @default ['module', 'jsnext:main', 'jsnext', 'main']
18
+ */
19
+ mainFields?: string[];
6
20
  }
7
21
  interface IonifyServerConfig {
8
22
  port?: number;
@@ -51,7 +65,19 @@ interface IonifyScopeHoistConfig {
51
65
  combineVariables?: boolean;
52
66
  }
53
67
  interface IonifyConfig {
68
+ /**
69
+ * Project root directory. All paths are resolved relative to root.
70
+ * Affects: module resolution, watcher, public URLs, CAS location.
71
+ * @default process.cwd()
72
+ */
54
73
  root?: string;
74
+ /**
75
+ * Entry point(s) for the application (relative to root or project-relative with leading '/').
76
+ * Accepts a single entry or multiple entries.
77
+ * @example '/src/main.tsx'
78
+ * @example ['src/main.tsx', 'src/admin.tsx']
79
+ */
80
+ entry?: string | string[];
55
81
  base?: string;
56
82
  mode?: string;
57
83
  /** Runtime-resolved minifier selection ('auto' by default). */
@@ -74,11 +100,56 @@ interface IonifyConfig {
74
100
  build?: IonifyBuildConfig;
75
101
  css?: IonifyCSSConfig;
76
102
  optimizeDeps?: {
103
+ /**
104
+ * Dependencies to pre-optimize on server start.
105
+ * Useful for ensuring common dependencies are bundled before first request.
106
+ * @example ['react', 'react-dom', 'lodash']
107
+ */
77
108
  include?: string[];
78
109
  exclude?: string[];
110
+ /**
111
+ * Generate sourcemaps for optimized dependencies.
112
+ * Disabled by default to speed up dependency optimization.
113
+ */
114
+ sourcemap?: boolean;
115
+ /**
116
+ * Bundle ESM dependencies instead of serving them as-is with proxies.
117
+ * When `true` (default), ESM deps are bundled into single self-contained
118
+ * files with synthesized named exports, eliminating request waterfalls.
119
+ * Set to `false` for debugging (to inspect original package source).
120
+ * @default true
121
+ */
122
+ bundleEsm?: boolean;
123
+ /**
124
+ * Build a framework "vendor preloader" module in dev to reduce cold-start waterfalls.
125
+ * - `'auto'` (default): detect framework deps from package.json and generate `vendor.<depsHash>.js`
126
+ * - `string[]`: explicit vendor specifiers (e.g. ['react','react-dom/client'])
127
+ * - `false`: disable vendor preloading
128
+ */
129
+ vendor?: "auto" | string[] | false;
130
+ /**
131
+ * @deprecated esbuildOptions are not supported in Ionify.
132
+ * Ionify uses native Rust optimizer. This option is ignored with a warning.
133
+ */
134
+ esbuildOptions?: Record<string, unknown>;
79
135
  };
80
136
  plugins?: any[];
137
+ /**
138
+ * Define global constant replacements.
139
+ * Values are JSON-stringified and replaced at compile time using AST transformation.
140
+ * @example
141
+ * define: {
142
+ * __APP_VERSION__: JSON.stringify('1.0.0'),
143
+ * __API_URL__: JSON.stringify('https://api.example.com'),
144
+ * 'process.env.NODE_ENV': JSON.stringify('production')
145
+ * }
146
+ */
81
147
  define?: Record<string, any>;
148
+ /**
149
+ * Environment variables to expose to the client.
150
+ * All variables starting with VITE_ or IONIFY_ are automatically exposed as import.meta.env.*
151
+ */
152
+ envPrefix?: string | string[];
82
153
  }
83
154
 
84
155
  interface BuildChunkAsset {
Binary file
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@ionify/ionify",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "A web infrastructure intelligence engine",
6
6
  "bin": {
7
- "ionify": "./dist/cli/index.js"
7
+ "ionify": "dist/cli/index.js"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "module": "./dist/index.js",
@@ -68,6 +68,7 @@
68
68
  "@types/node": "^22.0.0",
69
69
  "esbuild-plugin-alias": "^0.2.1",
70
70
  "eslint": "^9.14.0",
71
+ "lodash": "4.17.21",
71
72
  "tsconfig-paths": "^4.2.0",
72
73
  "tsup": "^8.0.0",
73
74
  "tsx": "^4.19.0",