reactolith 1.0.19
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/LICENSE +21 -0
- package/README.md +523 -0
- package/dist/cli/generate-web-types.cjs +607 -0
- package/dist/cli/generate-web-types.cjs.map +1 -0
- package/dist/index.cjs +611 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +236 -0
- package/dist/index.mjs +599 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +88 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { createRoot } from 'react-dom/client';
|
|
2
|
+
import React, { createContext, useState, useRef, useEffect, useContext, useCallback } from 'react';
|
|
3
|
+
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
const RouterContext = createContext(undefined);
|
|
6
|
+
function RouterProvider({ children }) {
|
|
7
|
+
const { router } = useApp();
|
|
8
|
+
const [loading, setLoading] = useState(false);
|
|
9
|
+
const [lastError, setLastError] = useState(null);
|
|
10
|
+
const errorId = useRef(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const start = () => setLoading(true);
|
|
13
|
+
const end = () => setLoading(false);
|
|
14
|
+
// The router emits: "render:failed", input, init, pushState, response, html, finalUrl
|
|
15
|
+
const onRenderFailed = (input, init, pushState, response, html, finalUrl) => {
|
|
16
|
+
errorId.current += 1;
|
|
17
|
+
setLastError({
|
|
18
|
+
id: errorId.current,
|
|
19
|
+
timestamp: Date.now(),
|
|
20
|
+
input,
|
|
21
|
+
init,
|
|
22
|
+
pushState,
|
|
23
|
+
response,
|
|
24
|
+
html,
|
|
25
|
+
finalUrl,
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
router.on("nav:started", start);
|
|
29
|
+
router.on("nav:ended", end);
|
|
30
|
+
router.on("render:failed", onRenderFailed);
|
|
31
|
+
return () => {
|
|
32
|
+
router.off("nav:started", start);
|
|
33
|
+
router.off("nav:ended", end);
|
|
34
|
+
router.off("render:failed", onRenderFailed);
|
|
35
|
+
};
|
|
36
|
+
}, [router]);
|
|
37
|
+
const clearError = () => setLastError(null);
|
|
38
|
+
return (jsx(RouterContext.Provider, { value: { router, loading, lastError, clearError }, children: children }));
|
|
39
|
+
}
|
|
40
|
+
function useRouter() {
|
|
41
|
+
const ctx = useContext(RouterContext);
|
|
42
|
+
if (!ctx) {
|
|
43
|
+
throw new Error("useRouter must be used inside <RouterProvider>");
|
|
44
|
+
}
|
|
45
|
+
return ctx;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const AppContext = createContext(undefined);
|
|
49
|
+
function useApp() {
|
|
50
|
+
const ctx = useContext(AppContext);
|
|
51
|
+
if (!ctx) {
|
|
52
|
+
throw new Error("useApp must be used inside <AppProvider>");
|
|
53
|
+
}
|
|
54
|
+
return ctx;
|
|
55
|
+
}
|
|
56
|
+
const AppProvider = ({ app, children, }) => {
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
app.element.classList.remove("hidden");
|
|
59
|
+
}, []);
|
|
60
|
+
return (jsx(AppContext.Provider, { value: app, children: jsx(RouterProvider, { children: children }) }));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isRelativeHref = (href) => {
|
|
64
|
+
if (!href)
|
|
65
|
+
return false;
|
|
66
|
+
if (href.startsWith("#"))
|
|
67
|
+
return false;
|
|
68
|
+
if (href.startsWith("//"))
|
|
69
|
+
return false;
|
|
70
|
+
return !/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
|
|
71
|
+
};
|
|
72
|
+
const hasNavBypassModifiers = (e) => e.defaultPrevented ||
|
|
73
|
+
e.button !== 0 ||
|
|
74
|
+
e.metaKey ||
|
|
75
|
+
e.ctrlKey ||
|
|
76
|
+
e.shiftKey ||
|
|
77
|
+
e.altKey;
|
|
78
|
+
class Router {
|
|
79
|
+
constructor(app, doc = document, fetchImpl = fetch) {
|
|
80
|
+
this.listeners = {};
|
|
81
|
+
this.app = app;
|
|
82
|
+
this.fetch = (input, init) => fetchImpl(input, init);
|
|
83
|
+
if (doc.defaultView) {
|
|
84
|
+
doc.defaultView.addEventListener("popstate", async () => {
|
|
85
|
+
await this.visit(location.pathname + location.search, { method: "GET" }, false);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
doc.addEventListener("click", (e) => this.onClick(e));
|
|
89
|
+
doc.addEventListener("submit", (e) => this.onSubmit(e));
|
|
90
|
+
}
|
|
91
|
+
ensureSet(type) {
|
|
92
|
+
const existing = this.listeners[type];
|
|
93
|
+
if (existing)
|
|
94
|
+
return existing;
|
|
95
|
+
const created = new Set();
|
|
96
|
+
// Upcast to the union that the field allows; no `any`.
|
|
97
|
+
this.listeners[type] = created;
|
|
98
|
+
return created;
|
|
99
|
+
}
|
|
100
|
+
emit(type, ...args) {
|
|
101
|
+
this.listeners[type]?.forEach((h) => h(...args));
|
|
102
|
+
}
|
|
103
|
+
on(type, handler) {
|
|
104
|
+
const set = this.ensureSet(type);
|
|
105
|
+
set.add(handler);
|
|
106
|
+
return () => this.off(type, handler);
|
|
107
|
+
}
|
|
108
|
+
off(type, handler) {
|
|
109
|
+
this.listeners[type]?.delete(handler);
|
|
110
|
+
}
|
|
111
|
+
async visit(input, init = { method: "GET" }, pushState = true) {
|
|
112
|
+
this.emit("nav:started", input, init, pushState);
|
|
113
|
+
const response = await this.fetch(input, init);
|
|
114
|
+
const html = await response.text();
|
|
115
|
+
const original = typeof input === "string" ? input : input.toString();
|
|
116
|
+
const finalUrl = response.redirected ? response.url : original;
|
|
117
|
+
const result = this.app.render(html);
|
|
118
|
+
if (result && pushState) {
|
|
119
|
+
history.pushState({}, "", finalUrl);
|
|
120
|
+
}
|
|
121
|
+
const event = result ? "render:success" : "render:failed";
|
|
122
|
+
this.emit(event, input, init, pushState, response, html, finalUrl);
|
|
123
|
+
this.emit("nav:ended", input, init, pushState, response, html, finalUrl);
|
|
124
|
+
return { result, response, html, finalUrl };
|
|
125
|
+
}
|
|
126
|
+
async onClick(event) {
|
|
127
|
+
// Ignore modified clicks, right/middle clicks, already-handled events
|
|
128
|
+
if (hasNavBypassModifiers(event))
|
|
129
|
+
return;
|
|
130
|
+
const link = event.target?.closest("a");
|
|
131
|
+
if (!link)
|
|
132
|
+
return;
|
|
133
|
+
const hrefAttr = link.getAttribute("href");
|
|
134
|
+
if (!isRelativeHref(hrefAttr))
|
|
135
|
+
return;
|
|
136
|
+
// Respect targets like _blank or any non-_self
|
|
137
|
+
if (link.target && link.target.toLowerCase() !== "_self")
|
|
138
|
+
return;
|
|
139
|
+
// Respect downloads and explicit external hints
|
|
140
|
+
if (link.hasAttribute("download"))
|
|
141
|
+
return;
|
|
142
|
+
const rel = link.getAttribute("rel") || "";
|
|
143
|
+
if (/\bexternal\b/i.test(rel))
|
|
144
|
+
return;
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
event.stopPropagation();
|
|
147
|
+
await this.visit(hrefAttr);
|
|
148
|
+
}
|
|
149
|
+
async onSubmit(event) {
|
|
150
|
+
const form = event.target;
|
|
151
|
+
if (!form)
|
|
152
|
+
return;
|
|
153
|
+
const actionAttr = form.getAttribute("action");
|
|
154
|
+
const isRelativeAction = actionAttr === null || isRelativeHref(actionAttr);
|
|
155
|
+
if (form.target && form.target.toLowerCase() !== "_self")
|
|
156
|
+
return;
|
|
157
|
+
if (!isRelativeAction)
|
|
158
|
+
return;
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
event.stopPropagation();
|
|
161
|
+
const formData = new FormData(form);
|
|
162
|
+
if (event.submitter instanceof HTMLButtonElement && event.submitter.name) {
|
|
163
|
+
formData.append(event.submitter.name, event.submitter.value || "");
|
|
164
|
+
}
|
|
165
|
+
const method = (form.method || "GET").toUpperCase();
|
|
166
|
+
let body = null;
|
|
167
|
+
let url = actionAttr ?? "";
|
|
168
|
+
if (method === "GET") {
|
|
169
|
+
const params = new URLSearchParams();
|
|
170
|
+
formData.forEach((value, key) => {
|
|
171
|
+
if (typeof value === "string")
|
|
172
|
+
params.append(key, value);
|
|
173
|
+
});
|
|
174
|
+
const q = params.toString();
|
|
175
|
+
const sep = url.includes("?") ? (q ? "&" : "") : q ? "?" : "";
|
|
176
|
+
url = `${url}${sep}${q}`;
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
body = formData;
|
|
180
|
+
}
|
|
181
|
+
await this.visit(url || location.pathname + location.search, {
|
|
182
|
+
method,
|
|
183
|
+
body,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async navigate(path) {
|
|
187
|
+
await this.visit(path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const toPascalCase = (str) => {
|
|
192
|
+
return str.replace(/(^\w|-\w)/g, (match) => match.replace(/-/, "").toUpperCase());
|
|
193
|
+
};
|
|
194
|
+
const normalizePropName = (name) => {
|
|
195
|
+
if (name.startsWith("json-")) {
|
|
196
|
+
name = name.substring(5);
|
|
197
|
+
}
|
|
198
|
+
name = toPascalCase(name);
|
|
199
|
+
return name.substring(0, 1).toLowerCase() + name.substring(1);
|
|
200
|
+
};
|
|
201
|
+
function getKey(element) {
|
|
202
|
+
return element.attributes.getNamedItem("key")?.value;
|
|
203
|
+
}
|
|
204
|
+
function getProps(element, component, isReactComponent = true) {
|
|
205
|
+
const props = {};
|
|
206
|
+
Array.from(element.attributes).forEach((attr) => {
|
|
207
|
+
if (attr.name !== "key" && !attr.name.startsWith("#")) {
|
|
208
|
+
let value = attr.value;
|
|
209
|
+
if (typeof value === "string" &&
|
|
210
|
+
attr.value.startsWith("{") &&
|
|
211
|
+
attr.value.endsWith("}")) {
|
|
212
|
+
value = React.createElement(component, {
|
|
213
|
+
is: value.substring(1, value.length - 1),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (attr.name.startsWith("json-")) {
|
|
217
|
+
props[normalizePropName(attr.name)] = JSON.parse(attr.value);
|
|
218
|
+
}
|
|
219
|
+
else if (!isReactComponent && attr.name.startsWith("data-")) {
|
|
220
|
+
props[attr.name] = attr.value;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Special case: Empty value will be transformed to bool true value
|
|
224
|
+
if (typeof value === "string" && value.length === 0) {
|
|
225
|
+
value = true;
|
|
226
|
+
}
|
|
227
|
+
props[attr.name === "class" ? "className" : normalizePropName(attr.name)] = value;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
return props;
|
|
232
|
+
}
|
|
233
|
+
function getSlots(element, component) {
|
|
234
|
+
const slots = {};
|
|
235
|
+
Array.from(element.childNodes).forEach((child) => {
|
|
236
|
+
if (child instanceof HTMLElement) {
|
|
237
|
+
Array.from(child.attributes).forEach((attr) => {
|
|
238
|
+
if (attr.name === "slot") {
|
|
239
|
+
slots[attr.value] = getChildren(child instanceof HTMLTemplateElement ? child.content : child, component);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
return slots;
|
|
245
|
+
}
|
|
246
|
+
function getChildren(element, component) {
|
|
247
|
+
return Array.from(element.childNodes)
|
|
248
|
+
.map((child, index) => {
|
|
249
|
+
if (child instanceof HTMLElement && child.hasAttribute("slot")) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
if (child instanceof Text) {
|
|
253
|
+
return child.textContent;
|
|
254
|
+
}
|
|
255
|
+
else if (child instanceof Element) {
|
|
256
|
+
const key = child.hasAttribute("key")
|
|
257
|
+
? child.getAttribute("key")
|
|
258
|
+
: index;
|
|
259
|
+
return (jsx(ReactolithComponent, { element: child, component: component }, key));
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
})
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
}
|
|
265
|
+
const ReactolithComponent = React.forwardRef(({ element, component: Component, ...props }, forwardedRef) => {
|
|
266
|
+
if (!element)
|
|
267
|
+
return null;
|
|
268
|
+
const tagName = element.tagName.toLowerCase();
|
|
269
|
+
const children = getChildren(element, Component);
|
|
270
|
+
const isReactComponent = tagName.includes("-") ||
|
|
271
|
+
document.createElement(tagName).constructor === HTMLUnknownElement;
|
|
272
|
+
const type = isReactComponent
|
|
273
|
+
? Component
|
|
274
|
+
: tagName;
|
|
275
|
+
const allProps = {
|
|
276
|
+
...getProps(element, Component, isReactComponent),
|
|
277
|
+
...getSlots(element, Component),
|
|
278
|
+
key: getKey(element),
|
|
279
|
+
...(isReactComponent ? { is: tagName } : {}),
|
|
280
|
+
...props,
|
|
281
|
+
ref: forwardedRef,
|
|
282
|
+
};
|
|
283
|
+
return React.createElement(type, allProps, ...children);
|
|
284
|
+
});
|
|
285
|
+
ReactolithComponent.displayName = "ReactolithComponent";
|
|
286
|
+
|
|
287
|
+
class App {
|
|
288
|
+
constructor(component, appProvider = AppProvider, selector = "#reactolith-app", root, doc = document, fetchImp = fetch) {
|
|
289
|
+
this.router = new Router(this, doc, fetchImp);
|
|
290
|
+
this.component = component;
|
|
291
|
+
this.appProvider = appProvider;
|
|
292
|
+
this.doc = doc;
|
|
293
|
+
if (typeof selector === "string") {
|
|
294
|
+
const selStr = selector;
|
|
295
|
+
selector = (doc) => doc.querySelector(selStr);
|
|
296
|
+
}
|
|
297
|
+
this.selector = selector;
|
|
298
|
+
const element = this.selector(doc);
|
|
299
|
+
if (!element) {
|
|
300
|
+
throw new Error("Could not find root element in document. Please check your selector!");
|
|
301
|
+
}
|
|
302
|
+
this.element = element;
|
|
303
|
+
this.root = root || createRoot(this.element);
|
|
304
|
+
// Auto-configure Mercure from data-mercure-hub-url attribute
|
|
305
|
+
const mercureHubUrl = this.element.getAttribute("data-mercure-hub-url");
|
|
306
|
+
if (mercureHubUrl) {
|
|
307
|
+
this.mercureConfig = {
|
|
308
|
+
hubUrl: mercureHubUrl,
|
|
309
|
+
withCredentials: this.element.hasAttribute("data-mercure-with-credentials"),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
this.renderElement(this.element);
|
|
313
|
+
}
|
|
314
|
+
render(document) {
|
|
315
|
+
if (typeof document === "string") {
|
|
316
|
+
const parser = new DOMParser();
|
|
317
|
+
document = parser.parseFromString(document, "text/html");
|
|
318
|
+
}
|
|
319
|
+
// Try to find the root element in the document
|
|
320
|
+
const element = this.selector(document);
|
|
321
|
+
if (!element) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
this.renderElement(element);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
renderElement(element) {
|
|
328
|
+
this.root.render(React.createElement(this.appProvider, {
|
|
329
|
+
app: this,
|
|
330
|
+
}, Array.from(element.children)
|
|
331
|
+
.filter((child) => child instanceof HTMLElement)
|
|
332
|
+
.map((element, key) => React.createElement(ReactolithComponent, {
|
|
333
|
+
key,
|
|
334
|
+
element,
|
|
335
|
+
component: this.component,
|
|
336
|
+
}))));
|
|
337
|
+
}
|
|
338
|
+
unmount() {
|
|
339
|
+
this.root.unmount();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
class Mercure {
|
|
344
|
+
constructor(app) {
|
|
345
|
+
this.eventSource = null;
|
|
346
|
+
this.listeners = {};
|
|
347
|
+
this.currentUrl = null;
|
|
348
|
+
this.options = null;
|
|
349
|
+
this.routerUnsubscribe = null;
|
|
350
|
+
this.app = app;
|
|
351
|
+
}
|
|
352
|
+
ensureSet(type) {
|
|
353
|
+
const existing = this.listeners[type];
|
|
354
|
+
if (existing)
|
|
355
|
+
return existing;
|
|
356
|
+
const created = new Set();
|
|
357
|
+
this.listeners[type] = created;
|
|
358
|
+
return created;
|
|
359
|
+
}
|
|
360
|
+
emit(type, ...args) {
|
|
361
|
+
this.listeners[type]?.forEach((h) => h(...args));
|
|
362
|
+
}
|
|
363
|
+
on(type, handler) {
|
|
364
|
+
const set = this.ensureSet(type);
|
|
365
|
+
set.add(handler);
|
|
366
|
+
return () => this.off(type, handler);
|
|
367
|
+
}
|
|
368
|
+
off(type, handler) {
|
|
369
|
+
this.listeners[type]?.delete(handler);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Subscribe to a Mercure hub for real-time updates.
|
|
373
|
+
* Automatically subscribes to the current pathname and re-subscribes on route changes.
|
|
374
|
+
*/
|
|
375
|
+
subscribe(options) {
|
|
376
|
+
// Store options for re-subscription
|
|
377
|
+
this.options = options;
|
|
378
|
+
// Unsubscribe from previous router listener
|
|
379
|
+
if (this.routerUnsubscribe) {
|
|
380
|
+
this.routerUnsubscribe();
|
|
381
|
+
}
|
|
382
|
+
// Listen to router navigation to re-subscribe with new pathname
|
|
383
|
+
this.routerUnsubscribe = this.app.router.on("render:success", () => {
|
|
384
|
+
this.connectToCurrentPath();
|
|
385
|
+
});
|
|
386
|
+
// Connect to current path
|
|
387
|
+
this.connectToCurrentPath();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Connect to EventSource with current pathname as topic
|
|
391
|
+
*/
|
|
392
|
+
connectToCurrentPath() {
|
|
393
|
+
if (!this.options)
|
|
394
|
+
return;
|
|
395
|
+
// Close existing connection if any
|
|
396
|
+
if (this.eventSource) {
|
|
397
|
+
this.eventSource.close();
|
|
398
|
+
if (this.currentUrl) {
|
|
399
|
+
this.emit("sse:disconnected", this.currentUrl);
|
|
400
|
+
}
|
|
401
|
+
this.eventSource = null;
|
|
402
|
+
}
|
|
403
|
+
const { hubUrl, lastEventId, withCredentials = false } = this.options;
|
|
404
|
+
// Build the subscription URL with current pathname as topic
|
|
405
|
+
const url = new URL(hubUrl);
|
|
406
|
+
const topic = window.location.pathname;
|
|
407
|
+
url.searchParams.append("topic", topic);
|
|
408
|
+
if (lastEventId) {
|
|
409
|
+
url.searchParams.set("lastEventID", lastEventId);
|
|
410
|
+
}
|
|
411
|
+
this.currentUrl = url.toString();
|
|
412
|
+
// Create EventSource connection
|
|
413
|
+
this.eventSource = new EventSource(this.currentUrl, {
|
|
414
|
+
withCredentials,
|
|
415
|
+
});
|
|
416
|
+
this.eventSource.onopen = () => {
|
|
417
|
+
this.emit("sse:connected", this.currentUrl);
|
|
418
|
+
};
|
|
419
|
+
this.eventSource.onmessage = async (event) => {
|
|
420
|
+
const html = event.data;
|
|
421
|
+
this.emit("sse:message", event, html);
|
|
422
|
+
// If message is empty or only whitespace, refetch the current route
|
|
423
|
+
if (!html || html.trim() === "") {
|
|
424
|
+
this.emit("refetch:started", event);
|
|
425
|
+
try {
|
|
426
|
+
const response = await this.app.router.visit(window.location.pathname + window.location.search, { method: "GET" }, false);
|
|
427
|
+
if (response.result) {
|
|
428
|
+
this.emit("refetch:success", event, response.html);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
this.emit("refetch:failed", event, new Error("Failed to render refetched content"));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
this.emit("refetch:failed", event, error);
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
// Process the HTML through the app's render method
|
|
440
|
+
const result = this.app.render(html);
|
|
441
|
+
if (result) {
|
|
442
|
+
this.emit("render:success", event, html);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
this.emit("render:failed", event, html);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
this.eventSource.onerror = (error) => {
|
|
449
|
+
this.emit("sse:error", error);
|
|
450
|
+
// If the connection is closed, emit disconnected
|
|
451
|
+
if (this.eventSource?.readyState === EventSource.CLOSED) {
|
|
452
|
+
this.emit("sse:disconnected", this.currentUrl);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Close the SSE connection
|
|
458
|
+
*/
|
|
459
|
+
close() {
|
|
460
|
+
// Unsubscribe from router events
|
|
461
|
+
if (this.routerUnsubscribe) {
|
|
462
|
+
this.routerUnsubscribe();
|
|
463
|
+
this.routerUnsubscribe = null;
|
|
464
|
+
}
|
|
465
|
+
if (this.eventSource) {
|
|
466
|
+
this.eventSource.close();
|
|
467
|
+
if (this.currentUrl) {
|
|
468
|
+
this.emit("sse:disconnected", this.currentUrl);
|
|
469
|
+
}
|
|
470
|
+
this.eventSource = null;
|
|
471
|
+
this.currentUrl = null;
|
|
472
|
+
}
|
|
473
|
+
this.options = null;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Check if currently connected
|
|
477
|
+
*/
|
|
478
|
+
get connected() {
|
|
479
|
+
return this.eventSource?.readyState === EventSource.OPEN;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Get the current connection URL
|
|
483
|
+
*/
|
|
484
|
+
get url() {
|
|
485
|
+
return this.currentUrl;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Get the last event ID (useful for reconnection)
|
|
489
|
+
*/
|
|
490
|
+
get lastEventId() {
|
|
491
|
+
// EventSource doesn't expose lastEventId directly,
|
|
492
|
+
// but you can track it via sse:message events
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Generic hook for subscribing to a Mercure topic and receiving raw message data.
|
|
499
|
+
* This is a low-level hook that handles EventSource connection management.
|
|
500
|
+
*
|
|
501
|
+
* @param topic - The Mercure topic to subscribe to
|
|
502
|
+
* @param onMessage - Callback when a message is received
|
|
503
|
+
* @param onError - Optional callback when an error occurs
|
|
504
|
+
*
|
|
505
|
+
* @internal This is a low-level hook. Use useMercureTopic or MercureLive instead.
|
|
506
|
+
*/
|
|
507
|
+
function useMercureEventSource(topic, onMessage, onError) {
|
|
508
|
+
const app = useApp();
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
if (!app.mercureConfig) {
|
|
511
|
+
console.warn(`useMercureEventSource: app.mercureConfig is not set. Please configure it before using Mercure features.`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const url = new URL(app.mercureConfig.hubUrl);
|
|
515
|
+
url.searchParams.append("topic", topic);
|
|
516
|
+
const eventSource = new EventSource(url.toString(), {
|
|
517
|
+
withCredentials: app.mercureConfig.withCredentials ?? false,
|
|
518
|
+
});
|
|
519
|
+
eventSource.onmessage = (event) => {
|
|
520
|
+
onMessage(event.data);
|
|
521
|
+
};
|
|
522
|
+
eventSource.onerror = (error) => {
|
|
523
|
+
if (onError) {
|
|
524
|
+
onError(error);
|
|
525
|
+
}
|
|
526
|
+
// EventSource will automatically reconnect
|
|
527
|
+
};
|
|
528
|
+
return () => eventSource.close();
|
|
529
|
+
}, [topic, app, onMessage, onError]);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Subscribe to a Mercure topic and receive live JSON data updates.
|
|
534
|
+
*
|
|
535
|
+
* @template T - The type of data expected from the topic
|
|
536
|
+
* @param topic - The Mercure topic to subscribe to
|
|
537
|
+
* @param initialValue - The initial value before any updates
|
|
538
|
+
* @returns The current value from the topic
|
|
539
|
+
*
|
|
540
|
+
* @example
|
|
541
|
+
* ```tsx
|
|
542
|
+
* // Simple usage with type inference
|
|
543
|
+
* const count = useMercureTopic('/notifications/count', 0);
|
|
544
|
+
*
|
|
545
|
+
* // With explicit type
|
|
546
|
+
* const status = useMercureTopic<'online' | 'offline'>('/status', 'offline');
|
|
547
|
+
*
|
|
548
|
+
* // With complex type
|
|
549
|
+
* interface Stats { visitors: number; sales: number; }
|
|
550
|
+
* const stats = useMercureTopic<Stats>('/stats', { visitors: 0, sales: 0 });
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
function useMercureTopic(topic, initialValue) {
|
|
554
|
+
const [value, setValue] = useState(initialValue);
|
|
555
|
+
const handleMessage = useCallback((data) => {
|
|
556
|
+
try {
|
|
557
|
+
const parsed = JSON.parse(data);
|
|
558
|
+
setValue(parsed);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
console.error("Failed to parse Mercure message:", error);
|
|
562
|
+
}
|
|
563
|
+
}, []);
|
|
564
|
+
useMercureEventSource(topic, handleMessage);
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function MercureLive({ topic, children }) {
|
|
569
|
+
const app = useApp();
|
|
570
|
+
const [content, setContent] = useState(children);
|
|
571
|
+
// Update content when children prop changes (e.g., during navigation)
|
|
572
|
+
useEffect(() => {
|
|
573
|
+
setContent(children);
|
|
574
|
+
}, [children]);
|
|
575
|
+
const handleMessage = useCallback((data) => {
|
|
576
|
+
try {
|
|
577
|
+
const parser = new DOMParser();
|
|
578
|
+
const doc = parser.parseFromString(data, "text/html");
|
|
579
|
+
const element = doc.body.firstElementChild;
|
|
580
|
+
if (element) {
|
|
581
|
+
setContent(jsx(ReactolithComponent, { element: element, component: app.component }));
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
console.warn("MercureLive: No element found in Mercure message");
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
console.error("MercureLive: Failed to parse message:", error);
|
|
589
|
+
}
|
|
590
|
+
}, [app.component]);
|
|
591
|
+
const handleError = useCallback((error) => {
|
|
592
|
+
console.error("MercureLive: EventSource error:", error);
|
|
593
|
+
}, []);
|
|
594
|
+
useMercureEventSource(topic, handleMessage, handleError);
|
|
595
|
+
return jsx(Fragment, { children: content });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export { App, AppProvider, Mercure, MercureLive, ReactolithComponent, Router, RouterProvider, useApp, useMercureEventSource, useMercureTopic, useRouter };
|
|
599
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "reactolith",
|
|
3
|
+
"version": "1.0.19",
|
|
4
|
+
"description": "Use HTML on the server to compose your react application.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Franz Mayr-Wilding",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/reactolith/reactolith"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/reactolith/reactolith/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/reactolith/reactolith",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"ssr"
|
|
19
|
+
],
|
|
20
|
+
"main": "./dist/index.cjs",
|
|
21
|
+
"module": "./dist/index.mjs",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.mjs",
|
|
27
|
+
"require": "./dist/index.cjs"
|
|
28
|
+
},
|
|
29
|
+
"./cli/generate-web-types": {
|
|
30
|
+
"require": "./cli/generate-web-types.cjs"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"bin": {
|
|
38
|
+
"generate-web-types": "./dist/cli/generate-web-types.cjs"
|
|
39
|
+
},
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"scripts": {
|
|
42
|
+
"clean": "rm -rf dist dist-webpack types",
|
|
43
|
+
"build": "npm run clean && npm run build:rollup",
|
|
44
|
+
"build:rollup": "rollup -c",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"build:release": "npm install && npm ci --dev && npm run typecheck && npm test -- --run && npm run build --if-present",
|
|
47
|
+
"test": "vitest",
|
|
48
|
+
"test:watch": "vitest --watch",
|
|
49
|
+
"test:coverage": "vitest run --coverage"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"react": "^19",
|
|
53
|
+
"react-dom": "^19"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@eslint/js": "^9.39.2",
|
|
57
|
+
"@react-types/shared": "^3.32.1",
|
|
58
|
+
"@rollup/plugin-commonjs": "^29.0.0",
|
|
59
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
60
|
+
"@testing-library/dom": "^10.4.1",
|
|
61
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
62
|
+
"@types/node": "^25.2.0",
|
|
63
|
+
"@types/react": "^19.2.10",
|
|
64
|
+
"@types/react-dom": "^19.2.3",
|
|
65
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
66
|
+
"eslint": "^9.39.2",
|
|
67
|
+
"eslint-config-prettier": "^10.1.8",
|
|
68
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
69
|
+
"eslint-plugin-react": "^7.37.5",
|
|
70
|
+
"globals": "^17.3.0",
|
|
71
|
+
"jiti": "^2.6.1",
|
|
72
|
+
"jsdom": "^28.0.0",
|
|
73
|
+
"prettier": "^3.8.1",
|
|
74
|
+
"react": "^19.2.4",
|
|
75
|
+
"react-dom": "^19.2.4",
|
|
76
|
+
"rollup": "^4.57.1",
|
|
77
|
+
"rollup-plugin-dts": "^6.3.0",
|
|
78
|
+
"rollup-plugin-peer-deps-external": "^2.2.4",
|
|
79
|
+
"rollup-plugin-typescript2": "^0.36.0",
|
|
80
|
+
"ts-morph": "^27.0.2",
|
|
81
|
+
"typescript": "^5.9.3",
|
|
82
|
+
"typescript-eslint": "^8.54.0",
|
|
83
|
+
"vitest": "^4.0.18"
|
|
84
|
+
},
|
|
85
|
+
"engines": {
|
|
86
|
+
"node": ">=18"
|
|
87
|
+
}
|
|
88
|
+
}
|