/// /// import '../debug-bar/types'; // Key must match sharedCssTracker.ts for cross-bundle dedup with HydratableIsland. const _css: Set = ((globalThis as unknown as Record).__mochi_loaded_css__ ??= new Set()) as Set; class ServerIsland extends HTMLElement { _loaded = false; connectedCallback() { if (this._loaded) { return; } this._loaded = true; const optionsRaw = this.getAttribute('server-options'); const options = optionsRaw ? JSON.parse(optionsRaw) : {}; if (this.getAttribute('defer-on') === 'visible') { // `display:contents` leaves this element without a layout box, so the firstElementChild is observed instead; with // no fallback children, the global `:empty { min-height: 1px }` rule keeps the wrapper itself observable. const target = this.firstElementChild || this; new IntersectionObserver( (entries, obs) => { for (const e of entries) { if (e.isIntersecting) { obs.disconnect(); this._fetchContent(options); return; } } }, { rootMargin: options.rootMargin || '0px' }, ).observe(target); return; } this._fetchContent(options); } async _fetchContent(options: Record = {}) { const g = (k: string) => this.getAttribute(k); const componentName = g('component-name'); if (!componentName) { return; } const tag = `[mochi] Server island "${componentName}"`; const ll = window.__mochi_log_level; const signedProps = g('signed-props'); const alsoHydrate = g('also-hydrate') || ''; const assetPrefix = g('data-asset-prefix'); let url = `${assetPrefix}/island/${encodeURIComponent(componentName)}`; const params = new URLSearchParams(); if (signedProps) { params.set('props', signedProps); } const qs = params.toString(); if (qs) { url += `?${qs}`; } if (url.length > 1800) { window.__mochi_warn?.(`${tag} URL is ${url.length} chars. Consider reducing prop size.`); } const maxRetries = typeof options.retries === 'number' ? options.retries : 9; let lastErr: unknown; for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { try { const response = await fetch(url); if (!response.ok) { if (response.status >= 400 && response.status < 500) { throw Object.assign(new Error(`HTTP ${response.status}`), { abort: true }); } throw new Error(`HTTP ${response.status}`); } const html = await response.text(); if (ll === 'log' || ll === 'debug') { console.log(`${tag} loaded (attempt ${attempt}, ${(html.length / 1024).toFixed(1)}kB, alsoHydrate=${alsoHydrate || 'none'})`); } const cssUrl = g('css-url'); if (cssUrl && !_css.has(cssUrl)) { _css.add(cssUrl); const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = cssUrl; document.head.appendChild(link); } // SAFETY: HTML comes from our own same-origin server-island endpoint with encrypted props. // If the island endpoint ever returns user-controlled content, this must be sanitized. this.innerHTML = html; return; } catch (err) { lastErr = err; if (err instanceof Error && 'abort' in err) { // A 4xx is deterministic, so the loop stops and falls through to the failure reporting below; rethrowing out // of this unawaited async method would surface as an unhandled rejection with the island stuck on its fallback. break; } if (ll !== 'silent' && ll !== 'error') { console.warn(`${tag} failed (attempt ${attempt}/${maxRetries + 1}): ${err}`); } if (attempt <= maxRetries) { const delay = attempt <= 3 ? 1000 : attempt <= 6 ? 3000 : 5000; await new Promise((r) => setTimeout(r, delay)); } } } const msg = `${tag} failed after ${maxRetries + 1} attempts: ${lastErr}`; if (ll !== 'silent') { console.error(msg); } window.__mochi_warn?.(msg); } } if (!customElements.get('mochi-server-island')) { customElements.define('mochi-server-island', ServerIsland); }