browser-module-runtime 0.0.1 → 0.0.2

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/html.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import type {Runtime} from './index.js';
2
- export interface HTMLController {readonly name:string;readonly runtime:Runtime;registerExisting():Promise<void>;scan():Promise<void>;disconnect():Promise<void>}
2
+ import type {WebComponentOptions} from './web-components.js';
3
+ export interface HTMLController {readonly name:string;readonly runtime:Runtime;defineElement(tag:string,options:Omit<WebComponentOptions,'tag'>):CustomElementConstructor;registerExisting():Promise<void>;scan():Promise<void>;disconnect():Promise<void>}
3
4
  export declare function registerHTML(runtime:Runtime,options?:{name?:string;root?:Document|Element;observe?:boolean}):HTMLController;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-module-runtime",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -12,13 +12,18 @@
12
12
  "import": "./src/html/index.js"
13
13
  },
14
14
  "./packages": "./src/engine/package-resolution/index.js",
15
- "./cache": "./src/engine/compile-cache.js"
15
+ "./cache": "./src/engine/compile-cache.js",
16
+ "./web-components": {
17
+ "types": "./web-components.d.ts",
18
+ "import": "./src/web-components/index.js"
19
+ }
16
20
  },
17
21
  "files": [
18
22
  "src",
19
23
  "index.d.ts",
20
24
  "html.d.ts",
21
- "README.md"
25
+ "README.md",
26
+ "web-components.d.ts"
22
27
  ],
23
28
  "engines": {
24
29
  "node": ">=20"
package/src/html/index.js CHANGED
@@ -1,6 +1,14 @@
1
1
  /** HTML controller with opt-in observation; not coupled to any UI framework. */
2
+ import {registerWebComponent} from '../web-components/index.js';
3
+
2
4
  const registries=new WeakMap();
3
5
 
6
+ function parseSchema(value,context){
7
+ if(!value)return undefined;
8
+ try {const parsed=JSON.parse(value);if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw new Error('expected an object');return parsed;}
9
+ catch(error){throw new Error(`Invalid prop schema in ${context}: ${error.message}`);}
10
+ }
11
+
4
12
  export function registerHTML(runtime,{name='default',root=globalThis.document,observe=false}={}) {
5
13
  if(!root)throw new TypeError('registerHTML requires a DOM root');
6
14
  const doc=root.ownerDocument||root;
@@ -12,6 +20,15 @@ export function registerHTML(runtime,{name='default',root=globalThis.document,ob
12
20
  let observer;
13
21
  let alive=true;
14
22
  let pending=Promise.resolve();
23
+ function defineElement(tag,config){return registerWebComponent(runtime,{...config,tag,document:doc});}
24
+ function elementOptions(el){
25
+ return {
26
+ module:el.getAttribute('module'),
27
+ export:el.getAttribute('component')||'default',
28
+ props:parseSchema(el.getAttribute('element-props')||el.getAttribute('props'),el.tagName.toLowerCase()),
29
+ shadow:el.getAttribute('shadow')||'none'
30
+ };
31
+ }
15
32
  function isOwned(node) {return node.getAttribute('data-runtime')===name || node.getAttribute('runtime')===name || (!node.hasAttribute('data-runtime')&&!node.hasAttribute('runtime')&&name==='default');}
16
33
  async function scan(){
17
34
  if(!alive)return;
@@ -21,8 +38,17 @@ export function registerHTML(runtime,{name='default',root=globalThis.document,ob
21
38
  if(!runtime.getSupportedTypes().includes(type))continue;
22
39
  const id=el.getAttribute('module');
23
40
  if(el.hasAttribute('data-src'))await runtime.loadSource(id,{type,url:el.getAttribute('data-src')});
24
- else runtime.define(id,el.textContent,{type});
41
+ else runtime.define(id,runtime.normalizeInlineSource(el.textContent,{type,id,element:el}),{type});
42
+ if(el.hasAttribute('custom-element')){
43
+ defineElement(el.getAttribute('custom-element'),elementOptions(el));
44
+ }
45
+ el.dataset.runtimeRegistered='true';
46
+ }
47
+ for(const el of root.querySelectorAll('runtime-element[tag][module]')){
48
+ if(!isOwned(el)||el.dataset.runtimeRegistered==='true')continue;
49
+ defineElement(el.getAttribute('tag'),elementOptions(el));
25
50
  el.dataset.runtimeRegistered='true';
51
+ el.hidden=true;
26
52
  }
27
53
  const targets=root.querySelectorAll('runtime-render');
28
54
  for(const el of targets){
@@ -44,5 +70,5 @@ export function registerHTML(runtime,{name='default',root=globalThis.document,ob
44
70
  if(win?.customElements&&!win.customElements.get('runtime-render')){
45
71
  win.customElements.define('runtime-render',class extends win.HTMLElement {});
46
72
  }
47
- return {name,runtime,registerExisting:scheduleScan,scan:scheduleScan,async disconnect(){if(!alive)return;alive=false;observer?.disconnect();await pending.catch(()=>{});for(const token of active.values()){token.cancelled=true;await token.handle?.dispose();}active.clear();registry.delete(name);}};
73
+ return {name,runtime,defineElement,registerExisting:scheduleScan,scan:scheduleScan,async disconnect(){if(!alive)return;alive=false;observer?.disconnect();await pending.catch(()=>{});for(const token of active.values()){token.cancelled=true;await token.handle?.dispose();}active.clear();registry.delete(name);}};
48
74
  }
package/src/index.js CHANGED
@@ -36,6 +36,12 @@ export function createRuntime(options={}) {
36
36
  getBuildArtifact(id){return artifactById.get(id);},
37
37
  getSourceType(id){return metadata.get(id)?.type;},
38
38
  getSupportedTypes(){return [...pipeline.formats];},
39
+ normalizeInlineSource(source,{type,id,element}={}) {
40
+ const owner=pipeline.owners.get(type);
41
+ const plugin=plugins.find(p=>p.name===owner?.plugin);
42
+ const normalize=plugin?.html?.normalizeInlineSource;
43
+ return normalize ? normalize(source,{type,id,element,runtime}) : source;
44
+ },
39
45
  async loadSource(id,{type,url=id}={}) {
40
46
  await runtime.ready;
41
47
  let source;
@@ -0,0 +1,11 @@
1
+ import type {Runtime} from '../../index.js';
2
+ export type PropType='string'|'number'|'boolean'|'json';
3
+ export type PropSchema=Record<string,PropType|{type:PropType;attribute?:string;default?:unknown}>;
4
+ export interface WebComponentOptions {
5
+ tag:string;module:string;export?:string;props?:PropSchema;
6
+ /** Only light DOM is supported at this stage. */
7
+ shadow?:'none'|false;
8
+ document?:Document;
9
+ }
10
+ /** Register a native custom element backed by the shared runtime mounting API. */
11
+ export declare function registerWebComponent(runtime:Runtime,options:WebComponentOptions):CustomElementConstructor;
@@ -0,0 +1,145 @@
1
+ /** Native custom elements for any browser-module-runtime renderer. No framework imports. */
2
+ const validName=/^[a-z][a-z0-9._-]*-[a-z0-9._-]+$/;
3
+ const types=new Set(['string','number','boolean','json']);
4
+
5
+ function normalizeSchema(input) {
6
+ if(input==null)return {};
7
+ if(typeof input!=='object'||Array.isArray(input))throw new TypeError('Web component props must be a schema object');
8
+ const schema={};
9
+ const attrs=new Set();
10
+ for(const [key,spec] of Object.entries(input)){
11
+ const config=typeof spec==='string'?{type:spec}:spec;
12
+ if(!config||typeof config!=='object'||!types.has(config.type))throw new TypeError(`Invalid prop type for ${key}; expected string, number, boolean, or json`);
13
+ const attribute=config.attribute||key.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());
14
+ if(!/^[a-z][a-z0-9._-]*$/.test(attribute)||attrs.has(attribute))throw new TypeError(`Invalid or duplicate attribute: ${attribute}`);
15
+ attrs.add(attribute);
16
+ schema[key]={...config,attribute};
17
+ }
18
+ return schema;
19
+ }
20
+ function convertAttribute(value,spec,key) {
21
+ if(value===null)return spec.default!==undefined?spec.default:spec.type==='boolean'?false:undefined;
22
+ if(spec.type==='string')return value;
23
+ if(spec.type==='boolean')return true; // standard HTML boolean attribute presence semantics
24
+ if(spec.type==='number'){
25
+ if(value.trim()===''||!Number.isFinite(Number(value)))throw new TypeError(`Invalid number for attribute ${spec.attribute}: ${value}`);
26
+ return Number(value);
27
+ }
28
+ try{return JSON.parse(value);}catch(e){throw new TypeError(`Invalid JSON for attribute ${spec.attribute}: ${e.message}`);}
29
+ }
30
+ const inferAttribute=value=>{
31
+ if(value==='')return true;
32
+ if(value==='true')return true;
33
+ if(value==='false')return false;
34
+ if(/^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i.test(value)&&Number.isFinite(Number(value)))return Number(value);
35
+ if(/^[\[{]/.test(value))try{return JSON.parse(value);}catch{}
36
+ return value;
37
+ };
38
+ function dispatch(element,name,detail){element.dispatchEvent(new (element.ownerDocument.defaultView.CustomEvent)(name,{detail,bubbles:true,composed:true}));}
39
+
40
+ /** Register a framework-rendered runtime module as a native custom element.
41
+ * v1 mounts in light DOM. Without renderer.updateProps, changes trigger a remount.
42
+ */
43
+ export function registerWebComponent(runtime,options={}) {
44
+ const {tag,module,export:exportName='default',props:propsOption,shadow='none',document:doc=globalThis.document}=options;
45
+ if(!runtime||typeof runtime.mount!=='function')throw new TypeError('registerWebComponent requires a runtime with mount()');
46
+ if(typeof tag!=='string'||!validName.test(tag))throw new TypeError(`Invalid custom element name: ${String(tag)} (must contain a hyphen)`);
47
+ if(typeof module!=='string'||!module)throw new TypeError('registerWebComponent requires a module id');
48
+ if(shadow!=='none'&&shadow!==false)throw new Error('Shadow DOM is not supported yet; use shadow: "none" for light DOM');
49
+ const win=doc?.defaultView;
50
+ if(!win?.customElements)throw new TypeError('registerWebComponent requires a browser document with customElements');
51
+ if(win.customElements.get(tag))throw new Error(`Custom element already registered: ${tag}`);
52
+ const schema=normalizeSchema(propsOption);
53
+ const attributes=Object.values(schema).map(s=>s.attribute);
54
+ class RuntimeElement extends win.HTMLElement {
55
+ static get observedAttributes(){return attributes;}
56
+ constructor(){
57
+ super();
58
+ this._runtimeHandle=null;
59
+ this._mounting=null;
60
+ this._generation=0;
61
+ this._propertyValues=Object.create(null);
62
+ this._updateTask=Promise.resolve();
63
+ // Properties assigned before customElements.define() must survive upgrade.
64
+ this._preUpgrade=[];
65
+ for(const key of Object.keys(schema))if(Object.hasOwn(this,key)){
66
+ this._preUpgrade.push([key,this[key]]);delete this[key];
67
+ }
68
+ }
69
+ _collectProps(){
70
+ const props={};
71
+ if(!attributes.length){
72
+ for(const attr of this.attributes)props[attr.name]=inferAttribute(attr.value);
73
+ }
74
+ for(const [key,spec] of Object.entries(schema)){
75
+ const value=convertAttribute(this.getAttribute(spec.attribute),spec,key);
76
+ if(value!==undefined)props[key]=value;
77
+ }
78
+ Object.assign(props,this._propertyValues);
79
+ return props;
80
+ }
81
+ _report(error){dispatch(this,'runtime-error',{error,module,tag});}
82
+ connectedCallback(){
83
+ for(const [key,value] of this._preUpgrade.splice(0))this[key]=value;
84
+ const version=++this._generation;
85
+ queueMicrotask(()=>{if(this.isConnected&&version===this._generation)this._start(version);});
86
+ }
87
+ disconnectedCallback(){
88
+ const version=++this._generation;
89
+ queueMicrotask(()=>{
90
+ if(!this.isConnected&&version===this._generation)this._stop().catch(e=>this._report(e));
91
+ });
92
+ }
93
+ attributeChangedCallback(attr,oldValue,newValue){
94
+ if(oldValue===newValue)return;
95
+ const entry=Object.entries(schema).find(([,spec])=>spec.attribute===attr);
96
+ if(entry)delete this._propertyValues[entry[0]];
97
+ if(this.isConnected)this._scheduleUpdate();
98
+ }
99
+ _scheduleUpdate(){
100
+ this._updateTask=this._updateTask.catch(()=>{}).then(async()=>{
101
+ // Allow batched changes and a mount in progress to settle before applying changes.
102
+ if(this._mounting)await this._mounting;
103
+ if(!this.isConnected||!this._runtimeHandle)return;
104
+ const props=this._collectProps();
105
+ if(this._runtimeHandle.capabilities.updateProps){
106
+ await this._runtimeHandle.updateProps(props);
107
+ }else{
108
+ // An explicit remount is preferable to silently ignoring changed attributes.
109
+ await this._runtimeHandle.dispose();this._runtimeHandle=null;
110
+ await this._start(this._generation);
111
+ }
112
+ }).catch(e=>this._report(e));
113
+ }
114
+ async _start(version){
115
+ if(this._runtimeHandle||this._mounting||!this.isConnected||version!==this._generation)return;
116
+ try{
117
+ const props=this._collectProps();
118
+ const mountPromise=runtime.mount({module,export:exportName,target:this,props});
119
+ this._mounting=mountPromise;
120
+ const handle=await mountPromise;
121
+ if(!this.isConnected||version!==this._generation){await handle.dispose();return;}
122
+ this._runtimeHandle=handle;
123
+ dispatch(this,'runtime-ready',{module,tag,handle});
124
+ }catch(e){this._report(e);}finally{
125
+ this._mounting=null;
126
+ if(this.isConnected&&!this._runtimeHandle&&version!==this._generation)
127
+ queueMicrotask(()=>this._start(this._generation));
128
+ }
129
+ }
130
+ async _stop(){
131
+ // A pending mount observes generation changes and disposes itself.
132
+ const handle=this._runtimeHandle;this._runtimeHandle=null;
133
+ if(handle){await handle.dispose();dispatch(this,'runtime-disposed',{module,tag});}
134
+ }
135
+ }
136
+ for(const key of Object.keys(schema)){
137
+ Object.defineProperty(RuntimeElement.prototype,key,{
138
+ configurable:true,enumerable:true,
139
+ get(){return Object.hasOwn(this._propertyValues,key)?this._propertyValues[key]:convertAttribute(this.getAttribute(schema[key].attribute),schema[key],key);},
140
+ set(value){this._propertyValues[key]=value;if(this.isConnected)this._scheduleUpdate();}
141
+ });
142
+ }
143
+ win.customElements.define(tag,RuntimeElement);
144
+ return RuntimeElement;
145
+ }
@@ -0,0 +1 @@
1
+ export * from './src/web-components/index.js';