cumstack 1.0.0

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,599 @@
1
+ /**
2
+ * cumstack Client Module
3
+ * client-side rendering and hydration
4
+ */
5
+
6
+ import { render } from './server/jsx.js';
7
+ import { createRouter } from './shared/router.js';
8
+ import { initI18n, setLanguage, extractLanguageFromRoute } from './shared/i18n.js';
9
+ import { onClimax } from './shared/reactivity.js';
10
+
11
+ let clientRouter = null;
12
+ let i18nConfig = null;
13
+ const registeredRoutes = new WeakMap();
14
+ let isRouterInitialized = false;
15
+
16
+ // hydration configuration
17
+ const hydrationConfig = {
18
+ isProduction: typeof window !== 'undefined' && window.location.hostname !== 'localhost',
19
+ logMismatches: true,
20
+ partialHydration: false,
21
+ };
22
+
23
+ // event delegation cache
24
+ const delegatedEvents = new Map();
25
+ const eventDelegationRoot = typeof document !== 'undefined' ? document : null;
26
+
27
+ /**
28
+ * get initial data from server
29
+ * @returns {Object|null} Parsed initial data or null
30
+ */
31
+ function getInitialData() {
32
+ if (typeof window === 'undefined') return null;
33
+ const script = document.getElementById('cumstack-data');
34
+ if (script && script.textContent) {
35
+ try {
36
+ return JSON.parse(script.textContent);
37
+ } catch (e) {
38
+ console.error('Failed to parse initial data:', e);
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * initialize i18n on client
46
+ * @param {Object} config - i18n configuration
47
+ */
48
+ function initializeI18n(config) {
49
+ i18nConfig = config;
50
+ const initialData = getInitialData();
51
+ const initialLang = initialData?.language || config.fallbackLng || 'en';
52
+ initI18n({
53
+ defaultLanguage: initialLang,
54
+ detectBrowser: config.defaultLng === 'auto',
55
+ });
56
+ setLanguage(initialLang);
57
+ if (typeof window !== 'undefined') window.__HONMOON_I18N_CONFIG__ = config;
58
+ }
59
+
60
+ /**
61
+ * router component (client)
62
+ * @param {Object} props - Component props
63
+ * @param {Object} [props.i18nOpt] - i18n options
64
+ * @param {*} props.children - Child elements
65
+ * @returns {*} Children
66
+ */
67
+ export function Router({ i18nOpt, children }) {
68
+ if (i18nOpt && !i18nConfig) initializeI18n(i18nOpt);
69
+ return children;
70
+ }
71
+
72
+ /**
73
+ * route component (client)
74
+ * @param {Object} props - Component props
75
+ * @param {string} props.path - Route path
76
+ * @param {Function} [props.component] - Component function
77
+ * @param {*} [props.element] - Element to render
78
+ * @param {*} [props.children] - Child elements
79
+ * @returns {null}
80
+ */
81
+ export function Route({ path, component, element, children }) {
82
+ if (!clientRouter) clientRouter = createRouter();
83
+ const handler = () => {
84
+ if (component) return component();
85
+ if (element) return element;
86
+ return children;
87
+ };
88
+
89
+ // prevent duplicate registration
90
+ const routeKey = { path };
91
+ if (!registeredRoutes.has(routeKey)) {
92
+ clientRouter.register(path, handler);
93
+ registeredRoutes.set(routeKey, true);
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * CowgirlCreampie component
100
+ * @param {Object} props - Component props
101
+ * @param {*} props.children - Child elements
102
+ * @returns {*} Children
103
+ */
104
+ export function CowgirlCreampie({ children }) {
105
+ return children;
106
+ }
107
+
108
+ /**
109
+ * configure hydration behavior
110
+ * @param {Object} config - Configuration options
111
+ * @param {boolean} [config.isProduction] - Whether in production mode
112
+ * @param {boolean} [config.logMismatches] - Whether to log hydration mismatches
113
+ * @param {boolean} [config.partialHydration] - Enable partial hydration
114
+ */
115
+ export function configureHydration(config) {
116
+ Object.assign(hydrationConfig, config);
117
+ }
118
+
119
+ /**
120
+ * log hydration warning (respects production mode)
121
+ * @param {string} message - Warning message
122
+ * @param {Object} [data] - Additional data
123
+ */
124
+ function logHydrationWarning(message, data) {
125
+ if (!hydrationConfig.isProduction && hydrationConfig.logMismatches) console.warn(`[Hydration] ${message}`, data || '');
126
+ }
127
+
128
+ /**
129
+ * setup event delegation for better performance
130
+ * @param {string} eventName - Event name (e.g., 'click')
131
+ * @param {HTMLElement} element - Element to attach listener to
132
+ * @param {Function} handler - Event handler
133
+ * @returns {Function} Cleanup function
134
+ */
135
+ function delegateEvent(eventName, element, handler) {
136
+ if (!eventDelegationRoot) return () => {};
137
+ const eventKey = `__cumstack_${eventName}_handler`;
138
+ if (!element[eventKey]) element[eventKey] = [];
139
+ element[eventKey].push(handler);
140
+ // setup delegated listener if not already present
141
+ if (!delegatedEvents.has(eventName)) {
142
+ const delegatedHandler = (e) => {
143
+ let target = e.target;
144
+ while (target && target !== eventDelegationRoot) {
145
+ const handlers = target[eventKey];
146
+ if (handlers) {
147
+ handlers.forEach((h) => h.call(target, e));
148
+ if (e.cancelBubble) return;
149
+ }
150
+ target = target.parentElement;
151
+ }
152
+ };
153
+ eventDelegationRoot.addEventListener(eventName, delegatedHandler, true);
154
+ delegatedEvents.set(eventName, delegatedHandler);
155
+ }
156
+ // return cleanup function
157
+ return () => {
158
+ const handlers = element[eventKey];
159
+ if (handlers) {
160
+ const index = handlers.indexOf(handler);
161
+ if (index > -1) handlers.splice(index, 1);
162
+ }
163
+ };
164
+ }
165
+
166
+ /**
167
+ * match vnode with DOM node using keys for better reconciliation
168
+ * @param {any} vnode - Virtual node
169
+ * @param {Node} domNode - DOM node
170
+ * @returns {boolean} Whether nodes match
171
+ */
172
+ function nodesMatch(vnode, domNode) {
173
+ // text nodes
174
+ if (typeof vnode === 'string' || typeof vnode === 'number') return domNode.nodeType === Node.TEXT_NODE;
175
+ // element nodes
176
+ if (vnode.type && domNode.nodeType === Node.ELEMENT_NODE) {
177
+ const tagMatch = domNode.tagName.toLowerCase() === vnode.type.toLowerCase();
178
+ // check key if present for better matching
179
+ if (vnode.props?.key && domNode.dataset?.key) return tagMatch && domNode.dataset.key === String(vnode.props.key);
180
+ return tagMatch;
181
+ }
182
+ return false;
183
+ }
184
+
185
+ /**
186
+ * hydrate existing dom with virtual node
187
+ * @param {any} vnode - Virtual node
188
+ * @param {Node} domNode - Existing DOM node
189
+ * @returns {Node} Hydrated DOM node
190
+ */
191
+ function hydrateDOMElement(vnode, domNode) {
192
+ // handle null/undefined
193
+ if (vnode == null || vnode === false) return domNode;
194
+ // handle text nodes
195
+ if (typeof vnode === 'string' || typeof vnode === 'number') {
196
+ if (domNode.nodeType === Node.TEXT_NODE) {
197
+ const vnodeText = String(vnode);
198
+ if (domNode.textContent !== vnodeText) {
199
+ logHydrationWarning('Text node mismatch', { expected: vnodeText, got: domNode.textContent });
200
+ domNode.textContent = vnodeText;
201
+ }
202
+ return domNode;
203
+ }
204
+ // mismatch: replace with text node
205
+ logHydrationWarning('Expected text node, got element');
206
+ const textNode = document.createTextNode(String(vnode));
207
+ domNode.parentNode?.replaceChild(textNode, domNode);
208
+ return textNode;
209
+ }
210
+
211
+ // handle arrays - arrays represent siblings, not a container
212
+ if (Array.isArray(vnode)) {
213
+ const fragment = document.createDocumentFragment();
214
+ let currentNode = domNode;
215
+ for (let i = 0; i < vnode.length; i++) {
216
+ const child = vnode[i];
217
+ if (child == null || child === false) continue;
218
+ if (currentNode) {
219
+ const nextNode = currentNode.nextSibling;
220
+ const hydratedNode = hydrateDOMElement(child, currentNode);
221
+ fragment.appendChild(hydratedNode);
222
+ currentNode = nextNode;
223
+ } else {
224
+ // no more DOM nodes, create new ones
225
+ const newNode = createDOMElementForHydration(child);
226
+ if (newNode) fragment.appendChild(newNode);
227
+ }
228
+ }
229
+ return fragment;
230
+ }
231
+ // handle fragments or already rendered components
232
+ if (!vnode.type) {
233
+ // if it's an object without a type, it's likely already rendered - treat as children
234
+ if (typeof vnode === 'object') {
235
+ // could be fragment result or component output
236
+ if (vnode.children) return hydrateDOMElement(vnode.children, domNode);
237
+ if (vnode.props?.children) return hydrateDOMElement(vnode.props.children, domNode);
238
+ // fallback
239
+ logHydrationWarning('Unknown vnode structure', vnode);
240
+ return domNode;
241
+ }
242
+ return hydrateDOMElement(vnode, domNode);
243
+ }
244
+ // handle element nodes
245
+ if (domNode.nodeType !== Node.ELEMENT_NODE) {
246
+ logHydrationWarning('Expected element node', { vnode, domNode });
247
+ const newElement = createDOMElementForHydration(vnode);
248
+ domNode.parentNode?.replaceChild(newElement, domNode);
249
+ return newElement;
250
+ }
251
+ const element = domNode;
252
+ // check tag name matches
253
+ if (element.tagName.toLowerCase() !== vnode.type.toLowerCase()) {
254
+ logHydrationWarning('Tag name mismatch', {
255
+ expected: vnode.type,
256
+ got: element.tagName.toLowerCase(),
257
+ });
258
+ const newElement = createDOMElementForHydration(vnode);
259
+ element.parentNode?.replaceChild(newElement, element);
260
+ return newElement;
261
+ }
262
+
263
+ // check partial hydration marker
264
+ if (hydrationConfig.partialHydration && element.hasAttribute('data-cumstack-static')) return element;
265
+ // hydrate props
266
+ const booleanAttrs = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple', 'autofocus']);
267
+ Object.entries(vnode.props || {}).forEach(([key, value]) => {
268
+ if (key === 'key') {
269
+ // store key as data attribute for reconciliation
270
+ if (!element.dataset.key) element.dataset.key = String(value);
271
+ } else if (key === 'className') {
272
+ if (element.className !== value) {
273
+ logHydrationWarning('className mismatch', { expected: value, got: element.className });
274
+ element.className = value;
275
+ }
276
+ } else if (key === 'style' && typeof value === 'object') {
277
+ Object.assign(element.style, value);
278
+ } else if (key.startsWith('on') && typeof value === 'function') {
279
+ // use event delegation for better performance
280
+ const eventName = key.slice(2).toLowerCase();
281
+ delegateEvent(eventName, element, value);
282
+ } else if (key === 'innerHTML') {
283
+ // skip innerHTML during hydration - it's already set by SSR
284
+ } else if (key === 'value' && (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA')) {
285
+ // special handling for form values
286
+ if (element.value !== value) element.value = value;
287
+ } else if (booleanAttrs.has(key)) {
288
+ // boolean attributes - check property not attribute
289
+ if (element[key] !== value) element[key] = value;
290
+ } else if (key === 'ref' && typeof value === 'function') {
291
+ // handle refs
292
+ value(element);
293
+ } else if (value != null && value !== false) {
294
+ const currentValue = element.getAttribute(key);
295
+ if (currentValue !== String(value)) {
296
+ logHydrationWarning('Attribute mismatch', { key, expected: value, got: currentValue });
297
+ element.setAttribute(key, value);
298
+ }
299
+ }
300
+ });
301
+ // hydrate children
302
+ const vnodeChildren = vnode.children || [];
303
+ let domChild = element.firstChild;
304
+ let vnodeIndex = 0;
305
+ // helper to skip whitespace-only text nodes
306
+ const skipWhitespace = (node) => {
307
+ while (node && node.nodeType === Node.TEXT_NODE && !node.textContent.trim()) node = node.nextSibling;
308
+ return node;
309
+ };
310
+ domChild = skipWhitespace(domChild);
311
+ while (vnodeIndex < vnodeChildren.length || domChild) {
312
+ if (vnodeIndex >= vnodeChildren.length) {
313
+ // extra dom nodes - remove them (unless whitespace)
314
+ const nextSibling = domChild.nextSibling;
315
+ if (domChild.nodeType === Node.TEXT_NODE && !domChild.textContent.trim()) {
316
+ domChild = skipWhitespace(nextSibling);
317
+ continue;
318
+ }
319
+ logHydrationWarning('Extra DOM nodes', domChild);
320
+ element.removeChild(domChild);
321
+ domChild = skipWhitespace(nextSibling);
322
+ continue;
323
+ }
324
+ if (!domChild) {
325
+ // missing dom nodes - create them
326
+ logHydrationWarning('Missing DOM nodes');
327
+ const newChild = createDOMElementForHydration(vnodeChildren[vnodeIndex]);
328
+ if (newChild) element.appendChild(newChild);
329
+ vnodeIndex++;
330
+ continue;
331
+ }
332
+ const vChild = vnodeChildren[vnodeIndex];
333
+ // skip null/false vnodes
334
+ if (vChild == null || vChild === false) {
335
+ vnodeIndex++;
336
+ continue;
337
+ }
338
+ // check if nodes match (using keys if available)
339
+ if (!nodesMatch(vChild, domChild)) {
340
+ // try to find matching node by key
341
+ if (vChild.props?.key) {
342
+ let foundNode = null;
343
+ let tempNode = domChild.nextSibling;
344
+ while (tempNode && !foundNode) {
345
+ if (nodesMatch(vChild, tempNode)) {
346
+ foundNode = tempNode;
347
+ break;
348
+ }
349
+ tempNode = tempNode.nextSibling;
350
+ }
351
+ if (foundNode) {
352
+ // move node to correct position
353
+ element.insertBefore(foundNode, domChild);
354
+ const nextSibling = foundNode.nextSibling;
355
+ hydrateDOMElement(vChild, foundNode);
356
+ domChild = skipWhitespace(nextSibling);
357
+ vnodeIndex++;
358
+ continue;
359
+ }
360
+ }
361
+ // no match found - replace node
362
+ logHydrationWarning('Node mismatch, replacing');
363
+ const newChild = createDOMElementForHydration(vChild);
364
+ if (newChild) element.replaceChild(newChild, domChild);
365
+ domChild = skipWhitespace(domChild.nextSibling);
366
+ vnodeIndex++;
367
+ continue;
368
+ }
369
+ const nextSibling = domChild.nextSibling;
370
+ hydrateDOMElement(vChild, domChild);
371
+ domChild = skipWhitespace(nextSibling);
372
+ vnodeIndex++;
373
+ }
374
+ return element;
375
+ }
376
+
377
+ /**
378
+ * create dom element from virtual node (used during hydration mismatches)
379
+ * @param {any} vnode - Virtual node
380
+ * @returns {Node|null}
381
+ */
382
+ function createDOMElementForHydration(vnode) {
383
+ // handle null/undefined
384
+ if (vnode == null || vnode === false) return null;
385
+ // handle text nodes
386
+ if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));
387
+ // handle arrays
388
+ if (Array.isArray(vnode)) {
389
+ const fragment = document.createDocumentFragment();
390
+ vnode.forEach((child) => {
391
+ const element = createDOMElementForHydration(child);
392
+ if (element) fragment.appendChild(element);
393
+ });
394
+ return fragment;
395
+ }
396
+ // handle components (already rendered)
397
+ if (!vnode.type) return createDOMElementForHydration(vnode);
398
+ // create element
399
+ const element = document.createElement(vnode.type);
400
+ // set props
401
+ Object.entries(vnode.props || {}).forEach(([key, value]) => {
402
+ if (key === 'key') element.dataset.key = String(value);
403
+ else if (key === 'className') element.className = value;
404
+ else if (key === 'style' && typeof value === 'object') Object.assign(element.style, value);
405
+ else if (key.startsWith('on') && typeof value === 'function') {
406
+ // use event delegation
407
+ const eventName = key.slice(2).toLowerCase();
408
+ delegateEvent(eventName, element, value);
409
+ } else if (key === 'innerHTML') element.innerHTML = value;
410
+ else if (key === 'ref' && typeof value === 'function') value(element);
411
+ else if (value != null && value !== false) element.setAttribute(key, value);
412
+ });
413
+
414
+ // append children
415
+ (vnode.children || []).forEach((child) => {
416
+ const childElement = createDOMElementForHydration(child);
417
+ if (childElement) element.appendChild(childElement);
418
+ });
419
+ return element;
420
+ }
421
+
422
+ /**
423
+ * cowgirl - mount and hydrate app
424
+ * @param {Function} app - App component function
425
+ * @param {HTMLElement|string} container - Container element or selector
426
+ * @returns {Function} Cleanup function to dispose the app
427
+ */
428
+ export function cowgirl(app, container) {
429
+ // validate container
430
+ const containerEl = typeof container === 'string' ? document.querySelector(container) : container;
431
+ if (!containerEl || !(containerEl instanceof HTMLElement)) throw new Error('cumstack: Container must be a valid HTMLElement or selector');
432
+ if (!clientRouter) clientRouter = createRouter();
433
+ // initialize router only once
434
+ if (!isRouterInitialized) {
435
+ clientRouter.init();
436
+ isRouterInitialized = true;
437
+ }
438
+ // check if we're hydrating ssr content
439
+ const isHydrating = containerEl.hasAttribute('data-cumstack-ssr') || containerEl.querySelector('[data-cumstack-ssr]') !== null;
440
+ // cleanup functions
441
+ const cleanupFns = [];
442
+ // track if first render (for hydration)
443
+ let isFirstRender = true;
444
+ // create effect with error boundary
445
+ const disposeEffect = onClimax(() => {
446
+ try {
447
+ const path = clientRouter.currentPath();
448
+ const match = clientRouter.matchRoute();
449
+ if (match) {
450
+ const content = match.handler({
451
+ path,
452
+ params: clientRouter.currentParams(),
453
+ });
454
+ if (isHydrating && isFirstRender && containerEl.firstChild) {
455
+ if (!hydrationConfig.isProduction) console.log('Hydrating existing content');
456
+ const appRoot = containerEl.querySelector('.app-root') || containerEl.firstChild;
457
+ hydrateDOMElement(content, appRoot);
458
+ isFirstRender = false;
459
+ } else render(content, containerEl);
460
+ }
461
+ } catch (error) {
462
+ console.error('cumstack render error:', error);
463
+ containerEl.innerHTML = `<div style="color: red; padding: 20px;">Render Error: ${error.message}</div>`;
464
+ }
465
+ });
466
+ cleanupFns.push(disposeEffect);
467
+ // handle navigation clicks
468
+ const clickHandler = (e) => {
469
+ const link = e.target.closest('a[href]');
470
+ if (!link) return;
471
+ const href = link.getAttribute('href');
472
+ if (!href) return;
473
+ const isSpaTwink = link.hasAttribute('data-spa-link');
474
+ const isNoSpa = link.hasAttribute('data-no-spa');
475
+ if (isNoSpa) return;
476
+ const isInternal = href.startsWith('/') && !href.startsWith('//');
477
+ if (isInternal || isSpaTwink) {
478
+ e.preventDefault();
479
+ if (i18nConfig?.explicitRouting) {
480
+ const { language } = extractLanguageFromRoute(href);
481
+ if (language) setLanguage(language);
482
+ }
483
+ clientRouter.navigate(href);
484
+ }
485
+ };
486
+ document.addEventListener('click', clickHandler);
487
+ cleanupFns.push(() => document.removeEventListener('click', clickHandler));
488
+ // setup prefetching with cleanup
489
+ const prefetchCleanup = setupPrefetching();
490
+ cleanupFns.push(prefetchCleanup);
491
+ // return cleanup function
492
+ return () => cleanupFns.forEach((fn) => fn());
493
+ }
494
+
495
+ /**
496
+ * setup link prefetching
497
+ * @returns {Function} Cleanup function
498
+ */
499
+ const prefetchedUrls = new Set();
500
+ function setupPrefetching() {
501
+ const observedTwinks = new Set();
502
+ const observer = new IntersectionObserver((entries) => {
503
+ entries.forEach((entry) => {
504
+ if (entry.isIntersecting) {
505
+ const link = entry.target;
506
+ const prefetchMode = link.getAttribute('data-prefetch');
507
+ const href = link.getAttribute('href');
508
+ if (prefetchMode === 'visible' && href && !prefetchedUrls.has(href)) prefetchUrl(href);
509
+ } else {
510
+ // unobserve links that leave viewport
511
+ const link = entry.target;
512
+ if (!entry.isIntersecting && observedTwinks.has(link)) {
513
+ observer.unobserve(link);
514
+ observedTwinks.delete(link);
515
+ }
516
+ }
517
+ });
518
+ });
519
+ const observeTwinks = () => {
520
+ // observe new links
521
+ document.querySelectorAll('a[data-prefetch="visible"]').forEach((link) => {
522
+ if (!observedTwinks.has(link)) {
523
+ observer.observe(link);
524
+ observedTwinks.add(link);
525
+ }
526
+ });
527
+ // clean up removed links
528
+ observedTwinks.forEach((link) => {
529
+ if (!document.contains(link)) {
530
+ observer.unobserve(link);
531
+ observedTwinks.delete(link);
532
+ }
533
+ });
534
+ };
535
+ observeTwinks();
536
+ const mutationObserver = new MutationObserver(observeTwinks);
537
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
538
+ const mouseoverHandler = (e) => {
539
+ const link = e.target.closest('a[data-prefetch="hover"]');
540
+ if (link) {
541
+ const href = link.getAttribute('href');
542
+ if (href && !prefetchedUrls.has(href)) {
543
+ prefetchUrl(href);
544
+ }
545
+ }
546
+ };
547
+ document.addEventListener('mouseover', mouseoverHandler);
548
+ // return cleanup function
549
+ return () => {
550
+ observer.disconnect();
551
+ mutationObserver.disconnect();
552
+ observedTwinks.clear();
553
+ document.removeEventListener('mouseover', mouseoverHandler);
554
+ };
555
+ }
556
+
557
+ /**
558
+ * prefetch url
559
+ * @param {string} href - URL to prefetch
560
+ * @param {number} [timeout=3000] - Timeout in milliseconds
561
+ */
562
+ async function prefetchUrl(href, timeout = 3000) {
563
+ if (!href || prefetchedUrls.has(href)) return;
564
+ prefetchedUrls.add(href);
565
+ const controller = new AbortController();
566
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
567
+ try {
568
+ await fetch(href, {
569
+ method: 'GET',
570
+ headers: { Accept: 'text/html' },
571
+ credentials: 'same-origin',
572
+ signal: controller.signal,
573
+ });
574
+ } catch (e) {
575
+ // only remove from cache if it's not an abort (which is expected)
576
+ if (e.name !== 'AbortError') {
577
+ prefetchedUrls.delete(href);
578
+ console.debug('Prefetch failed:', href, e.message);
579
+ }
580
+ } finally {
581
+ clearTimeout(timeoutId);
582
+ }
583
+ }
584
+
585
+ /**
586
+ * get current router
587
+ * @returns {Object|null} Router instance
588
+ */
589
+ export function useRouter() {
590
+ return clientRouter;
591
+ }
592
+
593
+ /**
594
+ * get i18n config
595
+ * @returns {Object|null} i18n configuration
596
+ */
597
+ export function useI18nConfig() {
598
+ return i18nConfig || (typeof window !== 'undefined' ? window.__HONMOON_I18N_CONFIG__ : null);
599
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * cumstack App Module
3
+ * main exports for app structure
4
+ */
5
+
6
+ // re-export based on environment
7
+ export * from './server.js';
8
+ export * from './client.js';