melina 1.0.2 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melina",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "A lightweight, islands-architecture web framework for Bun with Next.js-style routing.",
5
5
  "module": "./src/web.ts",
6
6
  "main": "./src/web.ts",
package/src/web.ts CHANGED
@@ -444,8 +444,8 @@ const builtAssets: Record<string, { content: ArrayBuffer; contentType: string }>
444
444
  let cachedRuntimePath: string | null = null;
445
445
 
446
446
  /**
447
- * Build the Melina Hangar runtime from TypeScript source
448
- * This bundles src/runtime/hangar.ts and serves it from memory
447
+ * Build the Melina client runtime from TypeScript source
448
+ * This bundles src/client.ts and serves it from memory
449
449
  */
450
450
  async function buildRuntime(): Promise<string> {
451
451
  // Return cached path if available
@@ -454,7 +454,7 @@ async function buildRuntime(): Promise<string> {
454
454
  }
455
455
 
456
456
  // Find the runtime source file (in the package, not the user's app)
457
- const runtimePath = path.resolve(__dirname, './runtime/hangar.ts');
457
+ const runtimePath = path.resolve(__dirname, './client.ts');
458
458
 
459
459
  if (!existsSync(runtimePath)) {
460
460
  throw new Error(`Melina runtime not found at: ${runtimePath}`);
@@ -1,399 +0,0 @@
1
- /**
2
- * Melina.js "Hangar" Runtime
3
- *
4
- * Single Hidden Root + Portal Registry Architecture
5
- *
6
- * This runtime manages client-side islands using a persistent React root
7
- * that survives body.innerHTML replacements during navigation.
8
- *
9
- * Key concepts:
10
- * - Hangar: The container appended to `<html>` (outside body), never destroyed
11
- * - Island Registry: Map of instanceId -> { Component, props, targetNode, storageNode }
12
- * - Storage Node: Persistent div that physically moves between placeholders
13
- * - Physical Reparenting: DOM nodes are moved, not recreated, preserving all state
14
- */
15
-
16
- declare global {
17
- interface Window {
18
- __MELINA_META__?: Record<string, string>;
19
- melinaNavigate: (href: string) => Promise<void>;
20
- }
21
- }
22
-
23
- // ========== TYPES ==========
24
- interface IslandEntry {
25
- name: string;
26
- Component: React.ComponentType<any>;
27
- props: Record<string, any>;
28
- targetNode: Element;
29
- storageNode: HTMLDivElement;
30
- }
31
-
32
- // ========== STATE ==========
33
- const componentCache: Record<string, React.ComponentType<any>> = {};
34
- const islandRegistry = new Map<string, IslandEntry>();
35
- const registryListeners = new Set<() => void>();
36
-
37
- function notifyRegistry() {
38
- registryListeners.forEach(fn => fn());
39
- }
40
-
41
- // ========== UTILITIES ==========
42
- function getIslandMeta(): Record<string, string> {
43
- const metaEl = document.getElementById('__MELINA_META__');
44
- if (!metaEl) return {};
45
- try {
46
- return JSON.parse(metaEl.textContent || '{}');
47
- } catch {
48
- return {};
49
- }
50
- }
51
-
52
- async function loadComponent(name: string): Promise<React.ComponentType<any> | null> {
53
- if (componentCache[name]) return componentCache[name];
54
-
55
- const meta = getIslandMeta();
56
- if (!meta[name]) return null;
57
-
58
- try {
59
- const module = await import(/* @vite-ignore */ meta[name]);
60
- componentCache[name] = module[name] || module.default;
61
- return componentCache[name];
62
- } catch (e) {
63
- console.error('[Melina] Failed to load', name, e);
64
- return null;
65
- }
66
- }
67
-
68
- // ========== PRE-LOAD MODULES ==========
69
- // Load all island components upfront to prevent pop-in
70
- async function preloadModules(): Promise<void> {
71
- const meta = getIslandMeta();
72
- const names = Object.keys(meta);
73
-
74
- await Promise.all(names.map(name => loadComponent(name)));
75
- console.log('[Melina] Pre-loaded', names.length, 'island modules');
76
- }
77
-
78
- // ========== HYDRATE ISLANDS ==========
79
- // Scans DOM for placeholders, registers/updates them in registry
80
- async function hydrateIslands(): Promise<void> {
81
- const placeholders = document.querySelectorAll('[data-melina-island]');
82
- const seenIds = new Set<string>();
83
-
84
- for (let i = 0; i < placeholders.length; i++) {
85
- const el = placeholders[i] as Element;
86
- const name = el.getAttribute('data-melina-island');
87
- if (!name) continue;
88
-
89
- const propsStr = (el.getAttribute('data-props') || '{}').replace(/&quot;/g, '"');
90
- const props = JSON.parse(propsStr);
91
- const instanceId = el.getAttribute('data-instance') || el.getAttribute('data-island-key') || `${name}-${i}`;
92
-
93
- seenIds.add(instanceId);
94
-
95
- const existing = islandRegistry.get(instanceId);
96
-
97
- if (existing) {
98
- // Island exists - update targetNode and MOVE storageNode physically
99
- existing.targetNode = el;
100
- existing.props = props;
101
-
102
- // Physical Reparenting: Move the storage node to new placeholder
103
- el.appendChild(existing.storageNode);
104
- console.log('[Melina] Moved island:', instanceId);
105
- } else {
106
- // New island - load component and register
107
- const Component = await loadComponent(name);
108
- if (Component) {
109
- // Create persistent storage node (the "lifeboat")
110
- const storageNode = document.createElement('div');
111
- storageNode.style.display = 'contents';
112
- storageNode.setAttribute('data-storage', instanceId);
113
- el.appendChild(storageNode);
114
-
115
- islandRegistry.set(instanceId, {
116
- name,
117
- Component,
118
- props,
119
- targetNode: el,
120
- storageNode
121
- });
122
- console.log('[Melina] Registered island:', instanceId);
123
- }
124
- }
125
- }
126
-
127
- // Garbage collection: Remove islands that no longer have placeholders
128
- for (const [id, item] of islandRegistry.entries()) {
129
- if (!seenIds.has(id)) {
130
- // Move to holding pen or just delete
131
- const holdingPen = document.getElementById('melina-holding-pen');
132
- if (holdingPen) {
133
- holdingPen.appendChild(item.storageNode);
134
- }
135
- islandRegistry.delete(id);
136
- console.log('[Melina] Collected island:', id);
137
- }
138
- }
139
-
140
- notifyRegistry();
141
- }
142
-
143
- // ========== SYNC ISLANDS (synchronous) ==========
144
- // Re-scans DOM for placeholders, updates targets for existing islands
145
- // This is SYNC so it can run inside View Transition callback
146
- function syncIslands(): void {
147
- console.log('[Melina] syncIslands called, registry size:', islandRegistry.size);
148
- const placeholders = document.querySelectorAll('[data-melina-island]');
149
- console.log('[Melina] Found placeholders:', placeholders.length);
150
-
151
- for (let i = 0; i < placeholders.length; i++) {
152
- const el = placeholders[i] as Element;
153
- const name = el.getAttribute('data-melina-island');
154
- if (!name) continue;
155
-
156
- const propsStr = (el.getAttribute('data-props') || '{}').replace(/&quot;/g, '"');
157
- const props = JSON.parse(propsStr);
158
- const instanceId = el.getAttribute('data-instance') || el.getAttribute('data-island-key') || `${name}-${i}`;
159
-
160
- console.log('[Melina] Looking for island:', instanceId, 'in registry');
161
-
162
- const existing = islandRegistry.get(instanceId);
163
-
164
- if (existing) {
165
- // CRITICAL: Update targetNode to NEW DOM element and move storageNode
166
- console.log('[Melina] Found island in registry, moving storageNode');
167
- existing.targetNode = el;
168
- existing.props = props;
169
- el.appendChild(existing.storageNode);
170
- console.log('[Melina] Moved island:', instanceId, 'storageNode children:', existing.storageNode.childNodes.length);
171
- } else {
172
- console.log('[Melina] Island NOT found in registry:', instanceId);
173
- console.log('[Melina] Registry keys:', Array.from(islandRegistry.keys()));
174
- }
175
- // New islands are ignored here - they need async component loading
176
- // which happens after transition via hydrateIslands()
177
- }
178
-
179
- notifyRegistry(); // Triggers React re-render immediately
180
- console.log('[Melina] syncIslands complete');
181
- }
182
-
183
- // ========== ISLAND MANAGER COMPONENT ==========
184
- async function initializeHangar(): Promise<void> {
185
- const React = await import('react');
186
- const ReactDOM = await import('react-dom/client');
187
- const { createPortal } = await import('react-dom');
188
-
189
- // Pre-load all island modules first
190
- await preloadModules();
191
-
192
- // Create hangar root OUTSIDE body (survives body.innerHTML replacement)
193
- let hangar = document.getElementById('melina-hangar');
194
- if (!hangar) {
195
- hangar = document.createElement('div');
196
- hangar.id = 'melina-hangar';
197
- hangar.style.display = 'contents';
198
- document.documentElement.appendChild(hangar);
199
- }
200
-
201
- // Holding pen for garbage collected islands (temporary storage)
202
- let holdingPen = document.getElementById('melina-holding-pen');
203
- if (!holdingPen) {
204
- holdingPen = document.createElement('div');
205
- holdingPen.id = 'melina-holding-pen';
206
- holdingPen.style.display = 'none';
207
- hangar.appendChild(holdingPen);
208
- }
209
-
210
- // Root container for React
211
- let rootContainer = document.getElementById('melina-hangar-root');
212
- if (!rootContainer) {
213
- rootContainer = document.createElement('div');
214
- rootContainer.id = 'melina-hangar-root';
215
- rootContainer.style.display = 'contents';
216
- hangar.appendChild(rootContainer);
217
- }
218
-
219
- // Island Manager Component
220
- function IslandManager(): React.ReactNode {
221
- const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
222
-
223
- // Subscribe to registry changes
224
- React.useEffect(() => {
225
- registryListeners.add(forceUpdate);
226
- return () => { registryListeners.delete(forceUpdate); };
227
- }, []);
228
-
229
- // Render all registered islands as portals INTO their storageNodes
230
- const portals: React.ReactNode[] = [];
231
- islandRegistry.forEach((island, instanceId) => {
232
- if (island.storageNode && island.storageNode.isConnected) {
233
- portals.push(
234
- createPortal(
235
- React.createElement(island.Component, {
236
- ...island.props,
237
- key: instanceId
238
- }),
239
- island.storageNode,
240
- instanceId
241
- )
242
- );
243
- }
244
- });
245
-
246
- return React.createElement(React.Fragment, null, portals);
247
- }
248
-
249
- // Create single React root
250
- const root = ReactDOM.createRoot(rootContainer);
251
- root.render(React.createElement(IslandManager));
252
-
253
- // Initial hydration
254
- await hydrateIslands();
255
-
256
- console.log('[Melina] Hangar initialized with', islandRegistry.size, 'islands');
257
- }
258
-
259
- // ========== NAVIGATION ==========
260
- async function navigate(href: string): Promise<void> {
261
- const fromPath = window.location.pathname;
262
- const toPath = new URL(href, window.location.origin).pathname;
263
- if (fromPath === toPath) return;
264
-
265
- console.log('[Melina] Navigate start:', fromPath, '->', toPath);
266
-
267
- // Step 1: FETCH new page content FIRST (before any transitions)
268
- let newDoc: Document;
269
- try {
270
- const response = await fetch(href, { headers: { 'X-Melina-Nav': '1' } });
271
- const htmlText = await response.text();
272
- const parser = new DOMParser();
273
- newDoc = parser.parseFromString(htmlText, 'text/html');
274
- console.log('[Melina] Fetched new page, body length:', newDoc.body.innerHTML.length);
275
- } catch (error) {
276
- console.error('[Melina] Fetch failed:', error);
277
- window.location.href = href;
278
- return;
279
- }
280
-
281
- // Step 2: Update URL (but DON'T dispatch navigation event yet!)
282
- // The event must fire INSIDE the View Transition callback so the
283
- // "old" snapshot captures the current state before the update
284
- window.history.pushState({}, '', href);
285
-
286
- // Step 3: SYNC update function - dispatches event INSIDE for proper animation
287
- const performUpdate = () => {
288
- // CRITICAL: Dispatch navigation-start INSIDE the View Transition callback
289
- // This way:
290
- // 1. "Old" snapshot was already taken (mini player)
291
- // 2. Event fires → MusicPlayer flushSync updates to expanded view
292
- // 3. DOM swap happens
293
- // 4. "New" snapshot is taken (expanded player)
294
- // 5. Browser animates between old and new!
295
- window.dispatchEvent(new CustomEvent('melina:navigation-start', {
296
- detail: { from: fromPath, to: toPath }
297
- }));
298
-
299
- document.title = newDoc.title;
300
- document.body.innerHTML = newDoc.body.innerHTML;
301
- console.log('[Melina] Full body swap');
302
- syncIslands();
303
- window.scrollTo(0, 0);
304
- };
305
-
306
- try {
307
- // Start View Transition with SYNC callback
308
- if (document.startViewTransition) {
309
- console.log('[Melina] Starting View Transition');
310
- const transition = document.startViewTransition(performUpdate);
311
- await transition.finished;
312
- console.log('[Melina] View Transition finished');
313
- } else {
314
- performUpdate();
315
- }
316
-
317
- // After transition, hydrate any NEW islands
318
- await hydrateIslands();
319
- console.log('[Melina] Navigation complete:', fromPath, '->', toPath);
320
-
321
- } catch (error) {
322
- console.error('[Melina] Navigation failed:', error);
323
- window.location.href = href;
324
- }
325
- }
326
-
327
- // Expose navigate globally
328
- window.melinaNavigate = navigate;
329
-
330
- // ========== LINK INTERCEPTION ==========
331
- document.addEventListener('click', (e: MouseEvent) => {
332
- // Skip if already handled by another handler (e.g., Link component)
333
- if (e.defaultPrevented) return;
334
-
335
- const link = (e.target as Element).closest('a[href]') as HTMLAnchorElement | null;
336
- if (!link) return;
337
-
338
- const href = link.getAttribute('href');
339
- if (
340
- e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ||
341
- link.hasAttribute('download') || link.hasAttribute('target') ||
342
- link.hasAttribute('data-no-intercept') ||
343
- !href || !href.startsWith('/')
344
- ) return;
345
-
346
- if (window.location.pathname === href) {
347
- e.preventDefault();
348
- return;
349
- }
350
-
351
- e.preventDefault();
352
- navigate(href);
353
- });
354
-
355
- // ========== POPSTATE ==========
356
- window.addEventListener('popstate', async () => {
357
- const href = window.location.pathname;
358
-
359
- // Core update logic - runs INSIDE View Transition callback
360
- const performUpdate = async () => {
361
- const response = await fetch(href, { headers: { 'X-Melina-Nav': '1' } });
362
- const html = await response.text();
363
- const newDoc = new DOMParser().parseFromString(html, 'text/html');
364
-
365
- document.title = newDoc.title;
366
-
367
- // FULL BODY REPLACEMENT
368
- document.body.innerHTML = newDoc.body.innerHTML;
369
-
370
- // SYNC: Find island placeholders, move portals
371
- syncIslands();
372
- };
373
-
374
- try {
375
- // Start View Transition - captures old state, then animates to new
376
- if (document.startViewTransition) {
377
- await document.startViewTransition(performUpdate).finished;
378
- await hydrateIslands();
379
- } else {
380
- await performUpdate();
381
- await hydrateIslands();
382
- }
383
- } catch (e) {
384
- console.error('[Melina] Popstate failed:', e);
385
- window.location.reload();
386
- }
387
- });
388
-
389
- // ========== INIT ==========
390
- if (document.readyState === 'loading') {
391
- document.addEventListener('DOMContentLoaded', initializeHangar);
392
- } else {
393
- initializeHangar();
394
- }
395
-
396
- console.log('[Melina] Hangar Runtime loaded');
397
-
398
- // Export for testing
399
- export { hydrateIslands, syncIslands, navigate, islandRegistry };