browser-module-runtime 0.0.8 → 0.0.10

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/README.md CHANGED
@@ -5,9 +5,22 @@ A framework-neutral browser module runtime with a native ESM linker, independent
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- npm install browser-module-runtime@0.0.8
8
+ npm install browser-module-runtime@0.0.10
9
9
  ```
10
10
 
11
+ ## Runtime-bound HTML integration (0.0.10)
12
+
13
+ ```js
14
+ import {createRuntime} from 'browser-module-runtime';
15
+ const runtime=createRuntime({plugins:[/* solid(), svelte(), octane() or vue() */]});
16
+ await runtime.html.process({scope:'demo'}); // process inert HTML declarations once
17
+ // Alternatively, without initial discovery:
18
+ const controller=await runtime.html.create({scope:'demo'});
19
+ // await controller.process(); // optional explicit later scan
20
+ ```
21
+
22
+ The stable `runtime.html` facade loads the browser HTML adapter lazily, reuses scoped controllers for the same runtime/root/scope, and disconnects facade-owned controllers when `runtime.dispose()` runs. No browser DOM access, scanning, or observer starts merely from `createRuntime()`. Advanced standalones remain available: `import {createHTMLRuntime,processHTML} from 'browser-module-runtime/html'`. Controllers remain autonomous for `<runtime-render>` and `<runtime-element>`, while inert source `<script>` declarations need `process()` or opt-in `observe:true`.
23
+
11
24
  ## Define, import and update modules
12
25
 
13
26
  ```js
package/index.d.ts CHANGED
@@ -11,10 +11,21 @@ export interface CompilerAdapter {
11
11
  compile(source: string, context: BuildContext): string | BuildState | Promise<string | BuildState>;
12
12
  analyze?(code: string, context: BuildContext): Array<{specifier:string;start:number;end:number;kind:string}> | Promise<Array<{specifier:string;start:number;end:number;kind:string}>>;
13
13
  }
14
+ /** Immutable handle to authored native custom-element light-DOM default children.
15
+ * The source nodes are retained by the custom-element host and never exposed.
16
+ * Each nodes() call returns newly cloned nodes for the renderer to own.
17
+ */
18
+ export interface ChildContentSnapshot {
19
+ readonly count: number;
20
+ nodes(): Node[];
21
+ html(): string;
22
+ }
14
23
  export interface MountContext<Props = Record<string, unknown>> {
15
24
  component: unknown;
16
25
  target: Element;
17
26
  props: Props;
27
+ /** Present for native Web Components with authored default children. */
28
+ children?: ChildContentSnapshot | null;
18
29
  runtime: Runtime;
19
30
  id: string;
20
31
  }
@@ -66,6 +77,13 @@ export interface RuntimeOptions {
66
77
  fallbackResolve?(specifier:string,importer?:string):string | undefined;
67
78
  }
68
79
  export interface Runtime {
80
+ /** Lazy, runtime-bound HTML integration. Requires a DOM only when called. */
81
+ html: {
82
+ /** Create or reuse controller without processing existing declarations. */
83
+ create(options?:import('./html.js').HTMLRuntimeOptions):Promise<import('./html.js').HTMLController>;
84
+ /** Create or reuse controller and process existing inert script declarations. */
85
+ process(options?:import('./html.js').HTMLRuntimeOptions):Promise<import('./html.js').HTMLController>;
86
+ };
69
87
  ready: Promise<void>;
70
88
  define(id:string,source:string,config?:{type?:string;format?:string;imports?:Record<string,unknown>}):string;
71
89
  defineMany(definitions:ModuleSource[]):string[];
@@ -75,7 +93,7 @@ export interface Runtime {
75
93
  loadSource(id:string,options?:{type?:string;url?:string}):Promise<string>;
76
94
  import(id:string):Promise<Record<string,any>>;
77
95
  compile(id:string,options?:Record<string,unknown>):Promise<{id:string;code?:string;url?:string;dependencies:string[];diagnostics:Diagnostic[];assets?:EmittedAsset[]}>;
78
- mount(options:{module:string;export?:string;target:Element|string;props?:Record<string,unknown>}):Promise<MountHandle>;
96
+ mount(options:{module:string;export?:string;target:Element|string;props?:Record<string,unknown>;children?:ChildContentSnapshot|null}):Promise<MountHandle>;
79
97
  remove(id:string,opts?:Record<string,unknown>):boolean;
80
98
  clear(opts?:Record<string,unknown>):string[];
81
99
  has(id:string):boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-module-runtime",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/html/index.js CHANGED
@@ -3,7 +3,11 @@ import {registerWebComponent} from '../web-components/index.js';
3
3
  import {getScopeRegistry,requestedScope,containsRoot} from './scopes.js';
4
4
  import {parseObjectProps as parseObject,readRenderProps,renderPropFingerprint} from './props.js';
5
5
 
6
- function emit(el,type,detail){const win=el.ownerDocument.defaultView;el.dispatchEvent(new win.CustomEvent(type,{detail,bubbles:true,composed:true}));}
6
+ function emit(el,type,detail){const win=el.ownerDocument.defaultView;return el.dispatchEvent(new win.CustomEvent(type,{detail,bubbles:true,composed:true,cancelable:type==='runtime-error'}));}
7
+ function report(el,error,detail={}){
8
+ state(el,'error');
9
+ if(emit(el,'runtime-error',{error,...detail}))el.ownerDocument.defaultView.console?.error?.(`[browser-module-runtime] <${el.localName}> failed:`,error);
10
+ }
7
11
  function state(el,value){el.setAttribute('data-runtime-state',value);}
8
12
  function rootDocument(root){return root?.nodeType===9?root:root?.ownerDocument;}
9
13
  function targetsWithin(root,selector){
@@ -36,7 +40,7 @@ function defineAutonomousElements(doc){
36
40
  queueMicrotask(()=>{if(!this.isConnected&&generation===this._generation){this._unsubscribe?.();this._unsubscribe=null;this._propObserver?.disconnect();this._propObserver=null;this._key=null;this._pending=null;this._release().catch(e=>this._report(e));state(this,'disposed');}});
37
41
  }
38
42
  attributeChangedCallback(name,oldValue,newValue){if(oldValue!==newValue&&this.isConnected)this._reconcile();}
39
- _report(error){state(this,'error');if(this._reported!==error){this._reported=error;emit(this,'runtime-error',{error,module:this.getAttribute('module'),scope:requestedScope(this)});}}
43
+ _report(error){if(this._reported!==error){this._reported=error;report(this,error,{module:this.getAttribute('module'),scope:requestedScope(this)});}}
40
44
  async _release(){
41
45
  this._abort?.abort();this._abort=null;
42
46
  const handle=this._handle;this._handle=null;
@@ -106,11 +110,11 @@ function defineAutonomousElements(doc){
106
110
  _register(){
107
111
  if(!this.isConnected||this._registered)return;
108
112
  const match=getScopeRegistry(doc).resolve(this);
109
- if(match.error){emit(this,'runtime-error',{error:match.error});return;}
113
+ if(match.error){report(this,match.error);return;}
110
114
  if(!match.controller){state(this,'pending');return;}
111
115
  if(!this.getAttribute('tag')||!this.getAttribute('module'))return;
112
116
  try{match.controller.defineElement(this.getAttribute('tag'),elementOptions(this));this._registered=true;this.hidden=true;state(this,'ready');emit(this,'runtime-ready',{tag:this.getAttribute('tag')});}
113
- catch(error){state(this,'error');emit(this,'runtime-error',{error});}
117
+ catch(error){report(this,error);}
114
118
  }
115
119
  }
116
120
  win.customElements.define('runtime-element',RuntimeElementDefinition);
@@ -124,10 +128,13 @@ export function createHTMLRuntime(runtime,options={}){
124
128
  const doc=rootDocument(root);
125
129
  if(!doc)throw new TypeError('createHTMLRuntime root must belong to a document');
126
130
  const registry=getScopeRegistry(doc);
131
+ if([...registry.controllers].some(c=>c.alive&&c.runtime===runtime&&c.scope===scope&&c.root===root)){
132
+ throw new Error(`HTML controller already exists for scope ${JSON.stringify(scope)} and the same runtime/root; use runtime.html.create() to reuse it`);
133
+ }
127
134
  defineAutonomousElements(doc);
128
135
  const resolvedScope=scope;
129
136
  let alive=true,observer=null,currentRoot=root,appendTarget=root,pending=Promise.resolve();
130
- const controller={id:++sequence,scope:resolvedScope,get root(){return currentRoot;},acceptUnscoped,runtime,get alive(){return alive;},defineElement};
137
+ const controller={id:++sequence,scope:resolvedScope,get root(){return currentRoot;},acceptUnscoped,observe,runtime,get alive(){return alive;},defineElement};
131
138
  function defineElement(tag,config){return registerWebComponent(runtime,{...config,tag,document:doc});}
132
139
  function owned(el){const match=registry.resolve(el);return match.controller===controller;}
133
140
  async function registerScript(el){
@@ -187,9 +194,11 @@ export function createHTMLRuntime(runtime,options={}){
187
194
  alive=false;observer?.disconnect();registry.remove(controller);
188
195
  await pending.catch(()=>{});
189
196
  }
197
+ Object.assign(controller,{registerScript,appendScript,appendAndRegister,process:scheduleScan,setRoot,moveTo,disconnect});
198
+ Object.defineProperty(controller,'appendTarget',{get(){return appendTarget;}});
190
199
  registry.add(controller);
191
200
  enableObservation();
192
- return {scope:resolvedScope,runtime,defineElement,registerScript,appendScript,appendAndRegister,process:scheduleScan,setRoot,moveTo,disconnect,get root(){return currentRoot;},get appendTarget(){return appendTarget;}};
201
+ return controller;
193
202
  }
194
203
 
195
204
  /** Create a scoped HTML controller and process existing source declarations once.
@@ -197,12 +206,37 @@ export function createHTMLRuntime(runtime,options={}){
197
206
  * Returns the controller so callers can use appendAndRegister(), setRoot() or disconnect().
198
207
  */
199
208
  export async function processHTML(runtime,options={}){
200
- const controller=createHTMLRuntime(runtime,options);
209
+ const {controller,created}=getOrCreateHTMLRuntime(runtime,options);
201
210
  try{
202
211
  await controller.process();
203
212
  return controller;
204
213
  }catch(error){
205
- await controller.disconnect();
214
+ if(created)await controller.disconnect();
206
215
  throw error;
207
216
  }
208
217
  }
218
+
219
+ /** Locate an existing controller in the authoritative document scope registry.
220
+ * Unlike the low-level creator, this never silently adds a second owner for
221
+ * the same runtime, scope and root. Used by the lazy runtime.html facade.
222
+ */
223
+ export function getOrCreateHTMLRuntime(runtime,options={}){
224
+ const root=options.root??globalThis.document;
225
+ if(!root||![1,9].includes(root.nodeType))throw new TypeError('runtime.html requires a Document or Element root (browser DOM)');
226
+ const doc=rootDocument(root);
227
+ if(!doc)throw new TypeError('runtime.html root must belong to a document');
228
+ const scope=options.scope??'default';
229
+ if(typeof scope!=='string'||!scope.trim())throw new TypeError('HTML scope must be a non-empty string');
230
+ const controllers=[...getScopeRegistry(doc).controllers].filter(c=>c.alive&&c.runtime===runtime&&c.scope===scope&&c.root===root);
231
+ if(controllers.length>1)throw new Error(`Duplicate HTML controllers for scope ${JSON.stringify(scope)} and the same runtime/root`);
232
+ const existing=controllers[0];
233
+ if(existing){
234
+ for(const key of ['observe','acceptUnscoped']){
235
+ if(Object.prototype.hasOwnProperty.call(options,key)&&options[key]!==existing[key]){
236
+ throw new Error(`HTML controller configuration conflict for scope ${JSON.stringify(scope)}: ${key} cannot change; disconnect the old controller first`);
237
+ }
238
+ }
239
+ return {controller:existing,created:false};
240
+ }
241
+ return {controller:createHTMLRuntime(runtime,{...options,scope,root}),created:true};
242
+ }
package/src/index.js CHANGED
@@ -23,9 +23,56 @@ export function createRuntime(options={}) {
23
23
  const styles=createStyleManager();
24
24
  const mounts=new Set();
25
25
  let disposed=false;
26
+ // A stable, DOM-free facade. Loading the optional HTML adapter and creating
27
+ // custom elements are deferred until the first explicit HTML call.
28
+ const htmlOwners=new Set();
29
+ const htmlProcesses=new WeakMap();
30
+ const obtainHTML=async(options={})=>{
31
+ if(disposed)throw new Error('Runtime is disposed');
32
+ const {getOrCreateHTMLRuntime}=await import('./html/index.js');
33
+ if(disposed)throw new Error('Runtime is disposed');
34
+ const result=getOrCreateHTMLRuntime(runtime,options);
35
+ if(result.created)htmlOwners.add(result.controller);
36
+ return result;
37
+ };
38
+ const html={
39
+ async create(options={}){
40
+ return (await obtainHTML(options)).controller;
41
+ },
42
+ async process(options={}){
43
+ const {controller,created}=await obtainHTML(options);
44
+ // The authoritative registry handles identity; this queue only orders
45
+ // explicit processing passes for the same controller.
46
+ const previous=htmlProcesses.get(controller)||Promise.resolve();
47
+ const job=previous.catch(()=>{}).then(async()=>{
48
+ if(disposed||!controller.alive)throw new Error('HTML controller or runtime is disposed');
49
+ await runtime.ready;
50
+ if(disposed||!controller.alive)throw new Error('HTML controller or runtime is disposed');
51
+ await controller.process();
52
+ return controller;
53
+ });
54
+ htmlProcesses.set(controller,job);
55
+ try{return await job;}
56
+ catch(error){
57
+ // Only the call which CREATED the controller may undo it after an
58
+ // initial scan failure. Existing owners (especially external) survive.
59
+ if(created&&htmlOwners.has(controller)){
60
+ htmlOwners.delete(controller);
61
+ await controller.disconnect();
62
+ }
63
+ throw error;
64
+ }
65
+ }
66
+ };
67
+ const disposeOwnedHTML=async()=>{
68
+ const controllers=[...htmlOwners];
69
+ htmlOwners.clear();
70
+ await Promise.allSettled(controllers.map(c=>c.disconnect()));
71
+ };
26
72
  const runtime={
27
73
  ...core,
28
74
  ready:null,
75
+ html,
29
76
  defineModule(id,namespace){rejectProtected(id);return core.defineModule(id,namespace);},
30
77
  defineUrl(id,url){rejectProtected(id);return core.defineUrl(id,url);},
31
78
  define(id,source,config={}) {
@@ -70,7 +117,7 @@ export function createRuntime(options={}) {
70
117
  },
71
118
  async import(id){await runtime.ready;return core.import(id);},
72
119
  async compile(id,opts){await runtime.ready;return core.compile(id,opts);},
73
- async mount({module:id,export:exportName='default',target,props={}}) {
120
+ async mount({module:id,export:exportName='default',target,props={},children=null}) {
74
121
  await runtime.ready;
75
122
  if(typeof target==='string')target=document.querySelector(target);
76
123
  if(!target)throw new TypeError(`Mount target not found: ${String(target)}`);
@@ -89,7 +136,7 @@ export function createRuntime(options={}) {
89
136
  for(const asset of built.assets||[]) {
90
137
  if(asset.type==='css')releases.push(styles.acquire({id:asset.id||`${id}:css`,content:asset.content},target.getRootNode?.()||target.ownerDocument));
91
138
  }
92
- handle=await renderer.mount({component:ns[exportName],target,props,runtime,id});
139
+ handle=await renderer.mount({component:ns[exportName],target,props,children,runtime,id});
93
140
  }catch(error){releases.forEach(fn=>fn());throw error;}
94
141
  let closed=false;
95
142
  const out={ready:Promise.resolve(),capabilities:{updateProps:typeof handle?.updateProps==='function'},
@@ -99,7 +146,7 @@ export function createRuntime(options={}) {
99
146
  mounts.add(out);
100
147
  return out;
101
148
  },
102
- async dispose(){if(disposed)return;disposed=true;await Promise.allSettled([...mounts].map(m=>m.dispose()));styles.dispose();core.dispose();for(const plugin of [...plugins].reverse())await plugin.dispose?.();}
149
+ async dispose(){if(disposed)return;disposed=true;await disposeOwnedHTML();await Promise.allSettled([...mounts].map(m=>m.dispose()));styles.dispose();core.dispose();for(const plugin of [...plugins].reverse())await plugin.dispose?.();}
103
150
  };
104
151
  runtime.ready=Promise.resolve().then(async()=>{for(const plugin of plugins)await plugin.configure?.({runtime,pipeline});});
105
152
  return runtime;
@@ -35,7 +35,26 @@ const inferAttribute=value=>{
35
35
  if(/^[\[{]/.test(value))try{return JSON.parse(value);}catch{}
36
36
  return value;
37
37
  };
38
- function dispatch(element,name,detail){element.dispatchEvent(new (element.ownerDocument.defaultView.CustomEvent)(name,{detail,bubbles:true,composed:true}));}
38
+
39
+ /** Stable framework-neutral snapshot of the authored default content.
40
+ * The original nodes remain parked in `fragment` for restoration and are NEVER
41
+ * handed to a renderer. Consumers receive fresh clones on every request.
42
+ */
43
+ function snapshotChildren(fragment,doc){
44
+ const originals=[...fragment.childNodes];
45
+ const meaningful=originals.filter(n=>n.nodeType!==8&&(n.nodeType!==3||n.nodeValue?.trim()));
46
+ if(!meaningful.length)return null;
47
+ return Object.freeze({
48
+ count: meaningful.length,
49
+ nodes(){return meaningful.map(n=>n.cloneNode(true));},
50
+ html(){
51
+ const wrapper=doc.createElement('div');
52
+ for(const node of meaningful)wrapper.appendChild(node.cloneNode(true));
53
+ return wrapper.innerHTML;
54
+ }
55
+ });
56
+ }
57
+ function dispatch(element,name,detail){return element.dispatchEvent(new (element.ownerDocument.defaultView.CustomEvent)(name,{detail,bubbles:true,composed:true,cancelable:true}));}
39
58
 
40
59
  /** Register a framework-rendered runtime module as a native custom element.
41
60
  * v1 mounts in light DOM. Without renderer.updateProps, changes trigger a remount.
@@ -56,6 +75,9 @@ export function registerWebComponent(runtime,options={}) {
56
75
  constructor(){
57
76
  super();
58
77
  this._runtimeHandle=null;
78
+ this._renderTarget=null;
79
+ this._authoredFragment=null;
80
+ this._childrenSnapshot=null;
59
81
  this._mounting=null;
60
82
  this._waitController=null;
61
83
  this._generation=0;
@@ -70,7 +92,7 @@ export function registerWebComponent(runtime,options={}) {
70
92
  _collectProps(){
71
93
  const props={};
72
94
  if(!attributes.length){
73
- for(const attr of this.attributes)props[attr.name]=inferAttribute(attr.value);
95
+ for(const attr of this.attributes)if(attr.name!=='data-runtime-state')props[attr.name]=inferAttribute(attr.value);
74
96
  }
75
97
  for(const [key,spec] of Object.entries(schema)){
76
98
  const value=convertAttribute(this.getAttribute(spec.attribute),spec,key);
@@ -79,7 +101,45 @@ export function registerWebComponent(runtime,options={}) {
79
101
  Object.assign(props,this._propertyValues);
80
102
  return props;
81
103
  }
82
- _report(error){dispatch(this,'runtime-error',{error,module,tag});}
104
+ _report(error){
105
+ this.setAttribute('data-runtime-state','error');
106
+ // Custom events may be consumed by the app; absent explicit handling, a
107
+ // failed asynchronous mount must not disappear without a console error.
108
+ if(dispatch(this,'runtime-error',{error,module,tag})){
109
+ this.ownerDocument.defaultView.console?.error?.(
110
+ `[browser-module-runtime] <${tag}> could not render ${module}:`,error);
111
+ }
112
+ }
113
+ _captureAuthoredContent(){
114
+ if(this._authoredFragment)return this._childrenSnapshot;
115
+ const fragment=this.ownerDocument.createDocumentFragment();
116
+ // Content belongs to the component only after its source has resolved;
117
+ // before that it remains visible as a pending/loading fallback.
118
+ for(const node of [...this.childNodes]){
119
+ if(node!==this._renderTarget)fragment.appendChild(node);
120
+ }
121
+ this._authoredFragment=fragment;
122
+ this._childrenSnapshot=snapshotChildren(fragment,this.ownerDocument);
123
+ return this._childrenSnapshot;
124
+ }
125
+ _restoreAuthoredContent(){
126
+ if(!this._authoredFragment)return;
127
+ this._renderTarget?.remove();this._renderTarget=null;
128
+ this.appendChild(this._authoredFragment); // Moves original nodes back, including listeners.
129
+ this._authoredFragment=null;
130
+ this._childrenSnapshot=null;
131
+ }
132
+ _mountTarget(){
133
+ if(this._renderTarget?.parentNode===this)return this._renderTarget;
134
+ // Render output stays isolated; captured authored HTML is provided to
135
+ // adapters as framework-native children rather than being left visible.
136
+ const target=this.ownerDocument.createElement('span');
137
+ target.setAttribute('data-runtime-mount','');
138
+ target.style.display='contents';
139
+ this.appendChild(target);
140
+ this._renderTarget=target;
141
+ return target;
142
+ }
83
143
  connectedCallback(){
84
144
  for(const [key,value] of this._preUpgrade.splice(0))this[key]=value;
85
145
  const version=++this._generation;
@@ -106,9 +166,12 @@ export function registerWebComponent(runtime,options={}) {
106
166
  const props=this._collectProps();
107
167
  if(this._runtimeHandle.capabilities.updateProps){
108
168
  await this._runtimeHandle.updateProps(props);
169
+ this.setAttribute('data-runtime-state','ready');
109
170
  }else{
110
171
  // An explicit remount is preferable to silently ignoring changed attributes.
111
172
  await this._runtimeHandle.dispose();this._runtimeHandle=null;
173
+ this._renderTarget?.remove();this._renderTarget=null;
174
+ // Keep parked authored content across a fallback remount.
112
175
  await this._start(this._generation);
113
176
  }
114
177
  }).catch(e=>this._report(e));
@@ -123,15 +186,18 @@ export function registerWebComponent(runtime,options={}) {
123
186
  await runtime.ready;
124
187
  if(typeof runtime.waitForModule==='function')await runtime.waitForModule(module,{signal:waitController.signal});
125
188
  if(waitController.signal.aborted||version!==this._generation||!this.isConnected)return null;
126
- return runtime.mount({module,export:exportName,target:this,props});
189
+ this.setAttribute('data-runtime-state','mounting');
190
+ const children=this._captureAuthoredContent();
191
+ return runtime.mount({module,export:exportName,target:this._mountTarget(),props,children});
127
192
  })();
128
193
  this._mounting=mountPromise;
129
194
  const handle=await mountPromise;
130
195
  if(!handle)return;
131
196
  if(!this.isConnected||version!==this._generation){await handle.dispose();return;}
132
197
  this._runtimeHandle=handle;
198
+ this.setAttribute('data-runtime-state','ready');
133
199
  dispatch(this,'runtime-ready',{module,tag,handle});
134
- }catch(e){if(e?.name!=='AbortError')this._report(e);}finally{
200
+ }catch(e){if(e?.name!=='AbortError'){this._restoreAuthoredContent();this._report(e);}}finally{
135
201
  if(this._waitController?.signal.aborted || version===this._generation)this._waitController=null;
136
202
  this._mounting=null;
137
203
  if(this.isConnected&&!this._runtimeHandle&&version!==this._generation)
@@ -143,6 +209,9 @@ export function registerWebComponent(runtime,options={}) {
143
209
  // A pending mount observes generation changes and disposes itself.
144
210
  const handle=this._runtimeHandle;this._runtimeHandle=null;
145
211
  if(handle){await handle.dispose();dispatch(this,'runtime-disposed',{module,tag});}
212
+ this._restoreAuthoredContent();
213
+ this._renderTarget?.remove();this._renderTarget=null;
214
+ this.removeAttribute('data-runtime-state');
146
215
  }
147
216
  }
148
217
  for(const key of Object.keys(schema)){