@surelle-ha/dead-fuse 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.
@@ -0,0 +1,56 @@
1
+ type ProjectState = "ACTIVE" | "WARNING" | "READONLY" | "LIMITED" | "LOCKED" | "EXPIRED" | "SLEEP" | "SELF_DESTRUCT";
2
+ interface DeadFuseConfig {
3
+ /** Unique project identifier (project_key from dashboard) */
4
+ projectId: string;
5
+ /**
6
+ * URL of the DeadFuse dashboard server.
7
+ * e.g. "https://your-dashboard.vercel.app"
8
+ * The SDK fetches configuration from `<master>/api/config` automatically.
9
+ * Optional: provide this OR both (supabaseUrl + supabaseAnonKey).
10
+ */
11
+ master?: string;
12
+ /**
13
+ * Public project token (public_token from dashboard).
14
+ * Used to authenticate the initial state fetch via PostgREST.
15
+ */
16
+ token: string;
17
+ /**
18
+ * Override the Supabase project URL.
19
+ * Optional — if omitted the SDK fetches this from `<master>/api/config`.
20
+ * Useful if you self-host Supabase or want zero extra round-trips.
21
+ */
22
+ supabaseUrl?: string;
23
+ /**
24
+ * Override the Supabase anon/public key.
25
+ * Optional — pair with supabaseUrl to bypass the /api/config fetch entirely.
26
+ */
27
+ supabaseAnonKey?: string;
28
+ /** State to apply immediately when the Realtime channel cannot connect */
29
+ fallbackMode?: ProjectState;
30
+ /** Grace period in days (informational — enforced by dashboard) */
31
+ gracePeriod?: number;
32
+ onActive?: () => void;
33
+ onWarning?: (message: string) => void;
34
+ onReadonly?: () => void;
35
+ onLimited?: () => void;
36
+ onLocked?: (message: string) => void;
37
+ onExpired?: () => void;
38
+ onSleep?: () => void;
39
+ onSelfDestruct?: () => void;
40
+ onDisconnect?: () => void;
41
+ onReconnect?: () => void;
42
+ }
43
+ interface StateMessage {
44
+ state: ProjectState;
45
+ message?: string;
46
+ }
47
+ interface DeadFuseInstance {
48
+ activate: (config: DeadFuseConfig) => void;
49
+ deactivate: () => void;
50
+ getState: () => ProjectState | null;
51
+ getConfig: () => DeadFuseConfig | null;
52
+ }
53
+
54
+ declare const DeadFuse: DeadFuseInstance;
55
+
56
+ export { DeadFuse, type DeadFuseConfig, type DeadFuseInstance, type ProjectState, type StateMessage, DeadFuse as default };
@@ -0,0 +1,56 @@
1
+ type ProjectState = "ACTIVE" | "WARNING" | "READONLY" | "LIMITED" | "LOCKED" | "EXPIRED" | "SLEEP" | "SELF_DESTRUCT";
2
+ interface DeadFuseConfig {
3
+ /** Unique project identifier (project_key from dashboard) */
4
+ projectId: string;
5
+ /**
6
+ * URL of the DeadFuse dashboard server.
7
+ * e.g. "https://your-dashboard.vercel.app"
8
+ * The SDK fetches configuration from `<master>/api/config` automatically.
9
+ * Optional: provide this OR both (supabaseUrl + supabaseAnonKey).
10
+ */
11
+ master?: string;
12
+ /**
13
+ * Public project token (public_token from dashboard).
14
+ * Used to authenticate the initial state fetch via PostgREST.
15
+ */
16
+ token: string;
17
+ /**
18
+ * Override the Supabase project URL.
19
+ * Optional — if omitted the SDK fetches this from `<master>/api/config`.
20
+ * Useful if you self-host Supabase or want zero extra round-trips.
21
+ */
22
+ supabaseUrl?: string;
23
+ /**
24
+ * Override the Supabase anon/public key.
25
+ * Optional — pair with supabaseUrl to bypass the /api/config fetch entirely.
26
+ */
27
+ supabaseAnonKey?: string;
28
+ /** State to apply immediately when the Realtime channel cannot connect */
29
+ fallbackMode?: ProjectState;
30
+ /** Grace period in days (informational — enforced by dashboard) */
31
+ gracePeriod?: number;
32
+ onActive?: () => void;
33
+ onWarning?: (message: string) => void;
34
+ onReadonly?: () => void;
35
+ onLimited?: () => void;
36
+ onLocked?: (message: string) => void;
37
+ onExpired?: () => void;
38
+ onSleep?: () => void;
39
+ onSelfDestruct?: () => void;
40
+ onDisconnect?: () => void;
41
+ onReconnect?: () => void;
42
+ }
43
+ interface StateMessage {
44
+ state: ProjectState;
45
+ message?: string;
46
+ }
47
+ interface DeadFuseInstance {
48
+ activate: (config: DeadFuseConfig) => void;
49
+ deactivate: () => void;
50
+ getState: () => ProjectState | null;
51
+ getConfig: () => DeadFuseConfig | null;
52
+ }
53
+
54
+ declare const DeadFuse: DeadFuseInstance;
55
+
56
+ export { DeadFuse, type DeadFuseConfig, type DeadFuseInstance, type ProjectState, type StateMessage, DeadFuse as default };
package/dist/index.js ADDED
@@ -0,0 +1,406 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DeadFuse: () => DeadFuse_default,
24
+ default: () => DeadFuse_default
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/connection.ts
29
+ var import_supabase_js = require("@supabase/supabase-js");
30
+
31
+ // src/stateManager.ts
32
+ var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
33
+ var currentState = null;
34
+ var interceptorsInstalled = false;
35
+ var originalFetch = null;
36
+ var originalXHROpen = null;
37
+ function getCurrentState() {
38
+ return currentState;
39
+ }
40
+ function setCurrentState(state) {
41
+ currentState = state;
42
+ applyStateEffects(state);
43
+ }
44
+ function isMutationBlocked() {
45
+ return currentState === "READONLY" || currentState === "LOCKED" || currentState === "EXPIRED" || currentState === "SLEEP" || currentState === "SELF_DESTRUCT";
46
+ }
47
+ function applyStateEffects(state) {
48
+ if (state === "READONLY" || state === "LOCKED" || state === "EXPIRED" || state === "SLEEP" || state === "SELF_DESTRUCT") {
49
+ installInterceptors();
50
+ } else {
51
+ if (interceptorsInstalled) {
52
+ removeInterceptors();
53
+ }
54
+ }
55
+ }
56
+ function installInterceptors() {
57
+ if (interceptorsInstalled) return;
58
+ interceptorsInstalled = true;
59
+ if (typeof window !== "undefined" && typeof window.fetch === "function") {
60
+ originalFetch = window.fetch.bind(window);
61
+ window.fetch = async function(input, init) {
62
+ const method = (init?.method || "GET").toUpperCase();
63
+ if (isMutationBlocked() && MUTATION_METHODS.has(method)) {
64
+ console.warn(
65
+ `[DeadFuse] Blocked ${method} request \u2014 project is in ${currentState} mode.`
66
+ );
67
+ return new Response(
68
+ JSON.stringify({
69
+ error: "Service unavailable",
70
+ reason: `Project is in ${currentState} mode`,
71
+ deadfuse: true
72
+ }),
73
+ {
74
+ status: 503,
75
+ headers: { "Content-Type": "application/json" }
76
+ }
77
+ );
78
+ }
79
+ return originalFetch(input, init);
80
+ };
81
+ }
82
+ if (typeof XMLHttpRequest !== "undefined") {
83
+ originalXHROpen = XMLHttpRequest.prototype.open;
84
+ XMLHttpRequest.prototype.open = function(method, url, async = true, username, password) {
85
+ this._deadfuseMethod = method.toUpperCase();
86
+ originalXHROpen.call(this, method, url, async, username, password);
87
+ };
88
+ const originalSend = XMLHttpRequest.prototype.send;
89
+ XMLHttpRequest.prototype.send = function(body) {
90
+ const method = this._deadfuseMethod || "GET";
91
+ if (isMutationBlocked() && MUTATION_METHODS.has(method)) {
92
+ console.warn(
93
+ `[DeadFuse] Blocked XHR ${method} \u2014 project is in ${currentState} mode.`
94
+ );
95
+ Object.defineProperty(this, "status", { get: () => 503 });
96
+ Object.defineProperty(this, "readyState", { get: () => 4 });
97
+ Object.defineProperty(this, "responseText", {
98
+ get: () => JSON.stringify({
99
+ error: "Service unavailable",
100
+ reason: `Project is in ${currentState} mode`,
101
+ deadfuse: true
102
+ })
103
+ });
104
+ this.dispatchEvent(new Event("readystatechange"));
105
+ this.dispatchEvent(new Event("load"));
106
+ return;
107
+ }
108
+ originalSend.call(this, body);
109
+ };
110
+ }
111
+ if (typeof window !== "undefined" && window.axios) {
112
+ const axios = window.axios;
113
+ if (axios.interceptors) {
114
+ axios.interceptors.request.use((config) => {
115
+ const method = (config.method || "get").toUpperCase();
116
+ if (isMutationBlocked() && MUTATION_METHODS.has(method)) {
117
+ console.warn(
118
+ `[DeadFuse] Blocked axios ${method} \u2014 project is in ${currentState} mode.`
119
+ );
120
+ return Promise.reject({
121
+ response: {
122
+ status: 503,
123
+ data: {
124
+ error: "Service unavailable",
125
+ reason: `Project is in ${currentState} mode`,
126
+ deadfuse: true
127
+ }
128
+ }
129
+ });
130
+ }
131
+ return config;
132
+ });
133
+ }
134
+ }
135
+ }
136
+ function removeInterceptors() {
137
+ if (!interceptorsInstalled) return;
138
+ interceptorsInstalled = false;
139
+ if (originalFetch && typeof window !== "undefined") {
140
+ window.fetch = originalFetch;
141
+ originalFetch = null;
142
+ }
143
+ if (originalXHROpen) {
144
+ XMLHttpRequest.prototype.open = originalXHROpen;
145
+ originalXHROpen = null;
146
+ }
147
+ }
148
+ function cleanupState() {
149
+ currentState = null;
150
+ removeInterceptors();
151
+ }
152
+
153
+ // src/events.ts
154
+ function dispatchStateEvent(state, message, config) {
155
+ switch (state) {
156
+ case "ACTIVE":
157
+ config.onActive?.();
158
+ break;
159
+ case "WARNING":
160
+ config.onWarning?.(message);
161
+ break;
162
+ case "READONLY":
163
+ config.onReadonly?.();
164
+ break;
165
+ case "LIMITED":
166
+ config.onLimited?.();
167
+ break;
168
+ case "LOCKED":
169
+ config.onLocked?.(message);
170
+ break;
171
+ case "EXPIRED":
172
+ config.onExpired?.();
173
+ break;
174
+ case "SLEEP":
175
+ config.onSleep?.();
176
+ break;
177
+ case "SELF_DESTRUCT":
178
+ config.onSelfDestruct?.();
179
+ break;
180
+ }
181
+ }
182
+
183
+ // src/connection.ts
184
+ var MAX_RECONNECT_ATTEMPTS = 10;
185
+ var BASE_RECONNECT_DELAY = 1e3;
186
+ var DeadFuseConnection = class {
187
+ constructor(config) {
188
+ this.supabase = null;
189
+ this.channel = null;
190
+ this.reconnectAttempts = 0;
191
+ this.reconnectTimer = null;
192
+ this.destroyed = false;
193
+ this.config = config;
194
+ }
195
+ connect() {
196
+ if (this.destroyed) return;
197
+ this._init();
198
+ }
199
+ // ── Bootstrap ──────────────────────────────────────────────────────────────
200
+ /**
201
+ * Resolve credentials then kick off the subscription + initial state fetch.
202
+ * If explicit supabaseUrl/Key are provided they are used immediately (no
203
+ * network round-trip). Otherwise we fetch them from the dashboard.
204
+ */
205
+ async _init() {
206
+ if (this.destroyed) return;
207
+ let supabaseUrl = this.config.supabaseUrl;
208
+ let supabaseAnonKey = this.config.supabaseAnonKey;
209
+ if (!supabaseUrl || !supabaseAnonKey) {
210
+ const masterUrl = this.config.master ?? (typeof window !== "undefined" ? window.location.origin : void 0);
211
+ if (!masterUrl) {
212
+ console.error(
213
+ "[DeadFuse] Provide either `master` (dashboard URL) or both `supabaseUrl` + `supabaseAnonKey`."
214
+ );
215
+ this._applyFallback();
216
+ return;
217
+ }
218
+ try {
219
+ const res = await fetch(`${masterUrl.replace(/\/$/, "")}/api/config`);
220
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
221
+ const json = await res.json();
222
+ supabaseUrl = json.supabaseUrl;
223
+ supabaseAnonKey = json.supabaseAnonKey;
224
+ } catch (err) {
225
+ console.warn("[DeadFuse] Could not fetch config from dashboard:", err);
226
+ this._applyFallback();
227
+ this._scheduleReconnect();
228
+ return;
229
+ }
230
+ }
231
+ if (this.destroyed) return;
232
+ this.supabase = (0, import_supabase_js.createClient)(supabaseUrl, supabaseAnonKey, {
233
+ realtime: { params: { eventsPerSecond: 2 } }
234
+ });
235
+ this._subscribe();
236
+ this._fetchInitialState();
237
+ }
238
+ // ── Realtime subscription ──────────────────────────────────────────────────
239
+ _subscribe() {
240
+ if (!this.supabase) return;
241
+ const channelName = `project:${this.config.projectId}`;
242
+ this.channel = this.supabase.channel(channelName, { config: { broadcast: { self: false } } }).on("broadcast", { event: "state" }, (payload) => {
243
+ const data = payload.payload;
244
+ if (data?.state) {
245
+ setCurrentState(data.state);
246
+ dispatchStateEvent(data.state, data.message ?? "", this.config);
247
+ }
248
+ }).subscribe((status) => {
249
+ if (status === "SUBSCRIBED") {
250
+ console.info(`[DeadFuse] Realtime channel connected: ${channelName}`);
251
+ if (this.reconnectAttempts > 0) this.config.onReconnect?.();
252
+ this.reconnectAttempts = 0;
253
+ this.channel?.track({ projectId: this.config.projectId, ts: Date.now() });
254
+ }
255
+ if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
256
+ console.warn(`[DeadFuse] Realtime channel error: ${status}`);
257
+ this.config.onDisconnect?.();
258
+ this._applyFallback();
259
+ this._scheduleReconnect();
260
+ }
261
+ if (status === "CLOSED" && !this.destroyed) {
262
+ this.config.onDisconnect?.();
263
+ this._applyFallback();
264
+ this._scheduleReconnect();
265
+ }
266
+ });
267
+ }
268
+ // ── Initial state fetch ────────────────────────────────────────────────────
269
+ async _fetchInitialState() {
270
+ if (!this.supabase) return;
271
+ const tryDashboardFallback = async () => {
272
+ const masterUrl = this.config.master ?? (typeof window !== "undefined" ? window.location.origin : void 0);
273
+ if (!masterUrl) return null;
274
+ try {
275
+ const res = await fetch(
276
+ `${masterUrl.replace(/\/$/, "")}/api/projects/${this.config.projectId}/initial-state?token=${encodeURIComponent(
277
+ this.config.token
278
+ )}`
279
+ );
280
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
281
+ return await res.json();
282
+ } catch (err) {
283
+ console.warn("[DeadFuse] Dashboard initial-state fallback failed:", err);
284
+ return null;
285
+ }
286
+ };
287
+ try {
288
+ const { data, error } = await this.supabase.from("projects").select("state, message").eq("project_key", this.config.projectId).eq("public_token", this.config.token).single();
289
+ if (error || !data) {
290
+ console.warn("[DeadFuse] Could not fetch initial state via Supabase:", error?.message);
291
+ const fallback = await tryDashboardFallback();
292
+ if (fallback) {
293
+ setCurrentState(fallback.state);
294
+ dispatchStateEvent(fallback.state, fallback.message ?? "", this.config);
295
+ return;
296
+ }
297
+ this._applyFallback();
298
+ return;
299
+ }
300
+ setCurrentState(data.state);
301
+ dispatchStateEvent(data.state, data.message ?? "", this.config);
302
+ } catch (err) {
303
+ console.warn("[DeadFuse] Initial state fetch failed:", err);
304
+ const fallback = await tryDashboardFallback();
305
+ if (fallback) {
306
+ setCurrentState(fallback.state);
307
+ dispatchStateEvent(fallback.state, fallback.message ?? "", this.config);
308
+ return;
309
+ }
310
+ this._applyFallback();
311
+ }
312
+ }
313
+ // ── Helpers ────────────────────────────────────────────────────────────────
314
+ _applyFallback() {
315
+ const fallback = this.config.fallbackMode;
316
+ if (fallback) {
317
+ setCurrentState(fallback);
318
+ dispatchStateEvent(fallback, "", this.config);
319
+ }
320
+ }
321
+ _scheduleReconnect() {
322
+ if (this.destroyed || this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
323
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
324
+ console.warn("[DeadFuse] Max reconnect attempts reached.");
325
+ }
326
+ return;
327
+ }
328
+ const delay = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts), 3e4);
329
+ this.reconnectAttempts++;
330
+ console.info(`[DeadFuse] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})\u2026`);
331
+ this.reconnectTimer = setTimeout(() => {
332
+ if (this.destroyed) return;
333
+ if (this.channel && this.supabase) {
334
+ this.supabase.removeChannel(this.channel);
335
+ this.channel = null;
336
+ }
337
+ this._init();
338
+ }, delay);
339
+ }
340
+ destroy() {
341
+ this.destroyed = true;
342
+ if (this.reconnectTimer) {
343
+ clearTimeout(this.reconnectTimer);
344
+ this.reconnectTimer = null;
345
+ }
346
+ if (this.channel && this.supabase) {
347
+ this.supabase.removeChannel(this.channel);
348
+ this.channel = null;
349
+ }
350
+ }
351
+ };
352
+
353
+ // src/DeadFuse.ts
354
+ var activeConnection = null;
355
+ var activeConfig = null;
356
+ var DeadFuse = {
357
+ activate(config) {
358
+ if (activeConnection) {
359
+ console.warn(
360
+ "[DeadFuse] Already activated. Call deactivate() first to reinitialize."
361
+ );
362
+ return;
363
+ }
364
+ if (!config.projectId) throw new Error("[DeadFuse] projectId is required.");
365
+ if (!config.token) throw new Error("[DeadFuse] token is required.");
366
+ const hasExplicitSupabase = Boolean(config.supabaseUrl && config.supabaseAnonKey);
367
+ const resolvedConfig = { ...config };
368
+ if (!resolvedConfig.master && !hasExplicitSupabase) {
369
+ if (typeof window !== "undefined") {
370
+ resolvedConfig.master = window.location.origin;
371
+ }
372
+ }
373
+ if (!resolvedConfig.master && !hasExplicitSupabase) {
374
+ throw new Error(
375
+ "[DeadFuse] Provide either a dashboard URL via `master` or both `supabaseUrl` + `supabaseAnonKey`."
376
+ );
377
+ }
378
+ activeConfig = resolvedConfig;
379
+ activeConnection = new DeadFuseConnection(resolvedConfig);
380
+ activeConnection.connect();
381
+ const connectMsg = resolvedConfig.master ? `dashboard at ${resolvedConfig.master}` : "explicit Supabase credentials";
382
+ console.info(
383
+ `[DeadFuse] Activated for project "${resolvedConfig.projectId}". Connecting via ${connectMsg}...`
384
+ );
385
+ },
386
+ deactivate() {
387
+ if (activeConnection) {
388
+ activeConnection.destroy();
389
+ activeConnection = null;
390
+ }
391
+ activeConfig = null;
392
+ cleanupState();
393
+ console.info("[DeadFuse] Deactivated.");
394
+ },
395
+ getState() {
396
+ return getCurrentState();
397
+ },
398
+ getConfig() {
399
+ return activeConfig;
400
+ }
401
+ };
402
+ var DeadFuse_default = DeadFuse;
403
+ // Annotate the CommonJS export names for ESM import in node:
404
+ 0 && (module.exports = {
405
+ DeadFuse
406
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,379 @@
1
+ // src/connection.ts
2
+ import { createClient } from "@supabase/supabase-js";
3
+
4
+ // src/stateManager.ts
5
+ var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
6
+ var currentState = null;
7
+ var interceptorsInstalled = false;
8
+ var originalFetch = null;
9
+ var originalXHROpen = null;
10
+ function getCurrentState() {
11
+ return currentState;
12
+ }
13
+ function setCurrentState(state) {
14
+ currentState = state;
15
+ applyStateEffects(state);
16
+ }
17
+ function isMutationBlocked() {
18
+ return currentState === "READONLY" || currentState === "LOCKED" || currentState === "EXPIRED" || currentState === "SLEEP" || currentState === "SELF_DESTRUCT";
19
+ }
20
+ function applyStateEffects(state) {
21
+ if (state === "READONLY" || state === "LOCKED" || state === "EXPIRED" || state === "SLEEP" || state === "SELF_DESTRUCT") {
22
+ installInterceptors();
23
+ } else {
24
+ if (interceptorsInstalled) {
25
+ removeInterceptors();
26
+ }
27
+ }
28
+ }
29
+ function installInterceptors() {
30
+ if (interceptorsInstalled) return;
31
+ interceptorsInstalled = true;
32
+ if (typeof window !== "undefined" && typeof window.fetch === "function") {
33
+ originalFetch = window.fetch.bind(window);
34
+ window.fetch = async function(input, init) {
35
+ const method = (init?.method || "GET").toUpperCase();
36
+ if (isMutationBlocked() && MUTATION_METHODS.has(method)) {
37
+ console.warn(
38
+ `[DeadFuse] Blocked ${method} request \u2014 project is in ${currentState} mode.`
39
+ );
40
+ return new Response(
41
+ JSON.stringify({
42
+ error: "Service unavailable",
43
+ reason: `Project is in ${currentState} mode`,
44
+ deadfuse: true
45
+ }),
46
+ {
47
+ status: 503,
48
+ headers: { "Content-Type": "application/json" }
49
+ }
50
+ );
51
+ }
52
+ return originalFetch(input, init);
53
+ };
54
+ }
55
+ if (typeof XMLHttpRequest !== "undefined") {
56
+ originalXHROpen = XMLHttpRequest.prototype.open;
57
+ XMLHttpRequest.prototype.open = function(method, url, async = true, username, password) {
58
+ this._deadfuseMethod = method.toUpperCase();
59
+ originalXHROpen.call(this, method, url, async, username, password);
60
+ };
61
+ const originalSend = XMLHttpRequest.prototype.send;
62
+ XMLHttpRequest.prototype.send = function(body) {
63
+ const method = this._deadfuseMethod || "GET";
64
+ if (isMutationBlocked() && MUTATION_METHODS.has(method)) {
65
+ console.warn(
66
+ `[DeadFuse] Blocked XHR ${method} \u2014 project is in ${currentState} mode.`
67
+ );
68
+ Object.defineProperty(this, "status", { get: () => 503 });
69
+ Object.defineProperty(this, "readyState", { get: () => 4 });
70
+ Object.defineProperty(this, "responseText", {
71
+ get: () => JSON.stringify({
72
+ error: "Service unavailable",
73
+ reason: `Project is in ${currentState} mode`,
74
+ deadfuse: true
75
+ })
76
+ });
77
+ this.dispatchEvent(new Event("readystatechange"));
78
+ this.dispatchEvent(new Event("load"));
79
+ return;
80
+ }
81
+ originalSend.call(this, body);
82
+ };
83
+ }
84
+ if (typeof window !== "undefined" && window.axios) {
85
+ const axios = window.axios;
86
+ if (axios.interceptors) {
87
+ axios.interceptors.request.use((config) => {
88
+ const method = (config.method || "get").toUpperCase();
89
+ if (isMutationBlocked() && MUTATION_METHODS.has(method)) {
90
+ console.warn(
91
+ `[DeadFuse] Blocked axios ${method} \u2014 project is in ${currentState} mode.`
92
+ );
93
+ return Promise.reject({
94
+ response: {
95
+ status: 503,
96
+ data: {
97
+ error: "Service unavailable",
98
+ reason: `Project is in ${currentState} mode`,
99
+ deadfuse: true
100
+ }
101
+ }
102
+ });
103
+ }
104
+ return config;
105
+ });
106
+ }
107
+ }
108
+ }
109
+ function removeInterceptors() {
110
+ if (!interceptorsInstalled) return;
111
+ interceptorsInstalled = false;
112
+ if (originalFetch && typeof window !== "undefined") {
113
+ window.fetch = originalFetch;
114
+ originalFetch = null;
115
+ }
116
+ if (originalXHROpen) {
117
+ XMLHttpRequest.prototype.open = originalXHROpen;
118
+ originalXHROpen = null;
119
+ }
120
+ }
121
+ function cleanupState() {
122
+ currentState = null;
123
+ removeInterceptors();
124
+ }
125
+
126
+ // src/events.ts
127
+ function dispatchStateEvent(state, message, config) {
128
+ switch (state) {
129
+ case "ACTIVE":
130
+ config.onActive?.();
131
+ break;
132
+ case "WARNING":
133
+ config.onWarning?.(message);
134
+ break;
135
+ case "READONLY":
136
+ config.onReadonly?.();
137
+ break;
138
+ case "LIMITED":
139
+ config.onLimited?.();
140
+ break;
141
+ case "LOCKED":
142
+ config.onLocked?.(message);
143
+ break;
144
+ case "EXPIRED":
145
+ config.onExpired?.();
146
+ break;
147
+ case "SLEEP":
148
+ config.onSleep?.();
149
+ break;
150
+ case "SELF_DESTRUCT":
151
+ config.onSelfDestruct?.();
152
+ break;
153
+ }
154
+ }
155
+
156
+ // src/connection.ts
157
+ var MAX_RECONNECT_ATTEMPTS = 10;
158
+ var BASE_RECONNECT_DELAY = 1e3;
159
+ var DeadFuseConnection = class {
160
+ constructor(config) {
161
+ this.supabase = null;
162
+ this.channel = null;
163
+ this.reconnectAttempts = 0;
164
+ this.reconnectTimer = null;
165
+ this.destroyed = false;
166
+ this.config = config;
167
+ }
168
+ connect() {
169
+ if (this.destroyed) return;
170
+ this._init();
171
+ }
172
+ // ── Bootstrap ──────────────────────────────────────────────────────────────
173
+ /**
174
+ * Resolve credentials then kick off the subscription + initial state fetch.
175
+ * If explicit supabaseUrl/Key are provided they are used immediately (no
176
+ * network round-trip). Otherwise we fetch them from the dashboard.
177
+ */
178
+ async _init() {
179
+ if (this.destroyed) return;
180
+ let supabaseUrl = this.config.supabaseUrl;
181
+ let supabaseAnonKey = this.config.supabaseAnonKey;
182
+ if (!supabaseUrl || !supabaseAnonKey) {
183
+ const masterUrl = this.config.master ?? (typeof window !== "undefined" ? window.location.origin : void 0);
184
+ if (!masterUrl) {
185
+ console.error(
186
+ "[DeadFuse] Provide either `master` (dashboard URL) or both `supabaseUrl` + `supabaseAnonKey`."
187
+ );
188
+ this._applyFallback();
189
+ return;
190
+ }
191
+ try {
192
+ const res = await fetch(`${masterUrl.replace(/\/$/, "")}/api/config`);
193
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
194
+ const json = await res.json();
195
+ supabaseUrl = json.supabaseUrl;
196
+ supabaseAnonKey = json.supabaseAnonKey;
197
+ } catch (err) {
198
+ console.warn("[DeadFuse] Could not fetch config from dashboard:", err);
199
+ this._applyFallback();
200
+ this._scheduleReconnect();
201
+ return;
202
+ }
203
+ }
204
+ if (this.destroyed) return;
205
+ this.supabase = createClient(supabaseUrl, supabaseAnonKey, {
206
+ realtime: { params: { eventsPerSecond: 2 } }
207
+ });
208
+ this._subscribe();
209
+ this._fetchInitialState();
210
+ }
211
+ // ── Realtime subscription ──────────────────────────────────────────────────
212
+ _subscribe() {
213
+ if (!this.supabase) return;
214
+ const channelName = `project:${this.config.projectId}`;
215
+ this.channel = this.supabase.channel(channelName, { config: { broadcast: { self: false } } }).on("broadcast", { event: "state" }, (payload) => {
216
+ const data = payload.payload;
217
+ if (data?.state) {
218
+ setCurrentState(data.state);
219
+ dispatchStateEvent(data.state, data.message ?? "", this.config);
220
+ }
221
+ }).subscribe((status) => {
222
+ if (status === "SUBSCRIBED") {
223
+ console.info(`[DeadFuse] Realtime channel connected: ${channelName}`);
224
+ if (this.reconnectAttempts > 0) this.config.onReconnect?.();
225
+ this.reconnectAttempts = 0;
226
+ this.channel?.track({ projectId: this.config.projectId, ts: Date.now() });
227
+ }
228
+ if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
229
+ console.warn(`[DeadFuse] Realtime channel error: ${status}`);
230
+ this.config.onDisconnect?.();
231
+ this._applyFallback();
232
+ this._scheduleReconnect();
233
+ }
234
+ if (status === "CLOSED" && !this.destroyed) {
235
+ this.config.onDisconnect?.();
236
+ this._applyFallback();
237
+ this._scheduleReconnect();
238
+ }
239
+ });
240
+ }
241
+ // ── Initial state fetch ────────────────────────────────────────────────────
242
+ async _fetchInitialState() {
243
+ if (!this.supabase) return;
244
+ const tryDashboardFallback = async () => {
245
+ const masterUrl = this.config.master ?? (typeof window !== "undefined" ? window.location.origin : void 0);
246
+ if (!masterUrl) return null;
247
+ try {
248
+ const res = await fetch(
249
+ `${masterUrl.replace(/\/$/, "")}/api/projects/${this.config.projectId}/initial-state?token=${encodeURIComponent(
250
+ this.config.token
251
+ )}`
252
+ );
253
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
254
+ return await res.json();
255
+ } catch (err) {
256
+ console.warn("[DeadFuse] Dashboard initial-state fallback failed:", err);
257
+ return null;
258
+ }
259
+ };
260
+ try {
261
+ const { data, error } = await this.supabase.from("projects").select("state, message").eq("project_key", this.config.projectId).eq("public_token", this.config.token).single();
262
+ if (error || !data) {
263
+ console.warn("[DeadFuse] Could not fetch initial state via Supabase:", error?.message);
264
+ const fallback = await tryDashboardFallback();
265
+ if (fallback) {
266
+ setCurrentState(fallback.state);
267
+ dispatchStateEvent(fallback.state, fallback.message ?? "", this.config);
268
+ return;
269
+ }
270
+ this._applyFallback();
271
+ return;
272
+ }
273
+ setCurrentState(data.state);
274
+ dispatchStateEvent(data.state, data.message ?? "", this.config);
275
+ } catch (err) {
276
+ console.warn("[DeadFuse] Initial state fetch failed:", err);
277
+ const fallback = await tryDashboardFallback();
278
+ if (fallback) {
279
+ setCurrentState(fallback.state);
280
+ dispatchStateEvent(fallback.state, fallback.message ?? "", this.config);
281
+ return;
282
+ }
283
+ this._applyFallback();
284
+ }
285
+ }
286
+ // ── Helpers ────────────────────────────────────────────────────────────────
287
+ _applyFallback() {
288
+ const fallback = this.config.fallbackMode;
289
+ if (fallback) {
290
+ setCurrentState(fallback);
291
+ dispatchStateEvent(fallback, "", this.config);
292
+ }
293
+ }
294
+ _scheduleReconnect() {
295
+ if (this.destroyed || this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
296
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
297
+ console.warn("[DeadFuse] Max reconnect attempts reached.");
298
+ }
299
+ return;
300
+ }
301
+ const delay = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts), 3e4);
302
+ this.reconnectAttempts++;
303
+ console.info(`[DeadFuse] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})\u2026`);
304
+ this.reconnectTimer = setTimeout(() => {
305
+ if (this.destroyed) return;
306
+ if (this.channel && this.supabase) {
307
+ this.supabase.removeChannel(this.channel);
308
+ this.channel = null;
309
+ }
310
+ this._init();
311
+ }, delay);
312
+ }
313
+ destroy() {
314
+ this.destroyed = true;
315
+ if (this.reconnectTimer) {
316
+ clearTimeout(this.reconnectTimer);
317
+ this.reconnectTimer = null;
318
+ }
319
+ if (this.channel && this.supabase) {
320
+ this.supabase.removeChannel(this.channel);
321
+ this.channel = null;
322
+ }
323
+ }
324
+ };
325
+
326
+ // src/DeadFuse.ts
327
+ var activeConnection = null;
328
+ var activeConfig = null;
329
+ var DeadFuse = {
330
+ activate(config) {
331
+ if (activeConnection) {
332
+ console.warn(
333
+ "[DeadFuse] Already activated. Call deactivate() first to reinitialize."
334
+ );
335
+ return;
336
+ }
337
+ if (!config.projectId) throw new Error("[DeadFuse] projectId is required.");
338
+ if (!config.token) throw new Error("[DeadFuse] token is required.");
339
+ const hasExplicitSupabase = Boolean(config.supabaseUrl && config.supabaseAnonKey);
340
+ const resolvedConfig = { ...config };
341
+ if (!resolvedConfig.master && !hasExplicitSupabase) {
342
+ if (typeof window !== "undefined") {
343
+ resolvedConfig.master = window.location.origin;
344
+ }
345
+ }
346
+ if (!resolvedConfig.master && !hasExplicitSupabase) {
347
+ throw new Error(
348
+ "[DeadFuse] Provide either a dashboard URL via `master` or both `supabaseUrl` + `supabaseAnonKey`."
349
+ );
350
+ }
351
+ activeConfig = resolvedConfig;
352
+ activeConnection = new DeadFuseConnection(resolvedConfig);
353
+ activeConnection.connect();
354
+ const connectMsg = resolvedConfig.master ? `dashboard at ${resolvedConfig.master}` : "explicit Supabase credentials";
355
+ console.info(
356
+ `[DeadFuse] Activated for project "${resolvedConfig.projectId}". Connecting via ${connectMsg}...`
357
+ );
358
+ },
359
+ deactivate() {
360
+ if (activeConnection) {
361
+ activeConnection.destroy();
362
+ activeConnection = null;
363
+ }
364
+ activeConfig = null;
365
+ cleanupState();
366
+ console.info("[DeadFuse] Deactivated.");
367
+ },
368
+ getState() {
369
+ return getCurrentState();
370
+ },
371
+ getConfig() {
372
+ return activeConfig;
373
+ }
374
+ };
375
+ var DeadFuse_default = DeadFuse;
376
+ export {
377
+ DeadFuse_default as DeadFuse,
378
+ DeadFuse_default as default
379
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@surelle-ha/dead-fuse",
3
+ "version": "1.0.4",
4
+ "description": "Lightweight license enforcement and remote control layer for deployed web applications",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.mjs",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
23
+ "dev": "tsup src/index.ts --format esm,cjs --dts --watch",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.0.2",
28
+ "typescript": "^5.4.5"
29
+ },
30
+ "keywords": [
31
+ "license",
32
+ "enforcement",
33
+ "remote-control",
34
+ "websocket",
35
+ "freelance"
36
+ ],
37
+ "license": "MIT",
38
+ "dependencies": {
39
+ "@supabase/supabase-js": "^2.102.1"
40
+ }
41
+ }