arcane-os 0.52.1 → 0.54.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/NOTICE +4 -1
  3. package/README.md +1 -1
  4. package/bin/arcane-test.mjs +2 -1
  5. package/browser-runtime/ai/browser-decisions.mjs +405 -0
  6. package/browser-runtime/ai/decision-runtime.mjs +281 -0
  7. package/browser-runtime/ai/decision-worker.mjs +67 -0
  8. package/browser-runtime/dependencies/event-pubsub/dependencies/strong-type/index.js +1151 -0
  9. package/browser-runtime/dependencies/event-pubsub/dependencies/strong-type/licence +21 -0
  10. package/browser-runtime/dependencies/event-pubsub/dependencies/strong-type/package.json +61 -0
  11. package/browser-runtime/dependencies/event-pubsub/index.js +2 -2
  12. package/browser-runtime/dependencies/event-pubsub/package.json +11 -4
  13. package/browser-runtime/dependencies/ws-share/WS.js +128 -0
  14. package/browser-runtime/dependencies/ws-share/licence.md +27 -0
  15. package/browser-runtime/dependencies/ws-share/package.json +64 -0
  16. package/browser-runtime/websocket-client.mjs +1 -0
  17. package/docs/event-manager.md +7 -4
  18. package/docs/reference/ai/browser-decisions.md +184 -0
  19. package/docs/reference/inventory/package-api.json +44 -2
  20. package/docs/reference/inventory/runtime-modules.json +17 -4
  21. package/docs/reference/protocols.md +76 -1
  22. package/docs/reference/runtime-modules.md +45 -2
  23. package/docs/reference/sdk-api.md +280 -6
  24. package/docs/reference/websocket-client.md +132 -0
  25. package/node_modules/event-pubsub/CHANGELOG.md +12 -1
  26. package/node_modules/event-pubsub/MIGRATION.md +8 -5
  27. package/node_modules/event-pubsub/README.md +75 -19
  28. package/node_modules/event-pubsub/SECURITY.md +3 -3
  29. package/node_modules/event-pubsub/index.js +2 -2
  30. package/node_modules/event-pubsub/package.json +11 -4
  31. package/package.json +9 -5
  32. package/runtime/arcane/modules/HTMLImport.js +81 -26
  33. package/runtime/arcane/modules/HTMLImportScript.js +3 -0
  34. package/src/import-map.mjs +416 -59
  35. package/src/index.mjs +2 -0
  36. package/src/packager/core.mjs +24 -6
  37. package/src/packager/native-modules.mjs +458 -0
  38. package/src/testing-loader.mjs +41 -20
  39. package/src/websocket.mjs +479 -0
package/src/index.mjs CHANGED
@@ -146,6 +146,8 @@ export {
146
146
  export {materializeInstalledSdkRuntime} from './installed-sdk-runtime.mjs';
147
147
  export {startDevServer} from './dev-server.mjs';
148
148
  export {startSourceExampleServer} from './source-server.mjs';
149
+ export {acceptWebSocket} from './websocket.mjs';
150
+ export {WS} from '../browser-runtime/websocket-client.mjs';
149
151
  export {
150
152
  NATIVE_BUILD_PLAN_PROTOCOL,
151
153
  NATIVE_BUILDER_PROTOCOL,
@@ -15,6 +15,7 @@ import {pathToFileURL} from 'node:url';
15
15
  import {appRelativeRoot,resolveAppRoot,resolvePackageOutputRoot} from '../app-layout.mjs';
16
16
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
17
17
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
18
+ import {materializeNativeModules} from './native-modules.mjs';
18
19
  import {
19
20
  applyPwaEntryReferences,
20
21
  inspectImportMapHtml,
@@ -421,7 +422,7 @@ async function assertContainedRealPath(root,candidate,label){
421
422
  }
422
423
  }
423
424
 
424
- async function loadContext(requestedWorkspaceRoot,appId){
425
+ async function loadContext(requestedWorkspaceRoot,appId,{outputDirectory}={}){
425
426
  const workspaceRoot=await realDirectory(normalizeWorkspaceRoot(requestedWorkspaceRoot),'Workspace root');
426
427
  const rootConfigPath=path.join(workspaceRoot,ROOT_CONFIG_NAME);
427
428
  const rootConfig=validateRootConfig(await readJson(rootConfigPath,ROOT_CONFIG_NAME),rootConfigPath);
@@ -431,6 +432,9 @@ async function loadContext(requestedWorkspaceRoot,appId){
431
432
  await assertContainedRealPath(appsRoot,appRoot,appId);
432
433
  const configPath=path.join(appRoot,APP_CONFIG_NAME);
433
434
  const config=validateAppConfig(await readJson(configPath,`${appId}/${APP_CONFIG_NAME}`),appId,rootConfig,configPath);
435
+ if(outputDirectory!==undefined){
436
+ config.outputDirectory=normalizeRelativePath(outputDirectory,'package outputDirectory');
437
+ }
434
438
  return {
435
439
  workspaceRoot,
436
440
  rootConfig,
@@ -841,7 +845,10 @@ async function replaceDirectory(stagingRoot,outputRoot){
841
845
  }
842
846
 
843
847
  async function packageWithContext(context,options={}){
844
- const {signal,onEvent,browserPwa=true}=options;
848
+ const {signal,onEvent,browserPwa=true,moduleFormat='import-map'}=options;
849
+ if(!['import-map','native'].includes(moduleFormat)){
850
+ throw new TypeError('packageApp moduleFormat must be "import-map" or "native".');
851
+ }
845
852
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
846
853
  const appPath=appRelativeRoot(context.rootConfig,context.appId);
847
854
  const entryPath=appPackagePath(context,context.config.entry);
@@ -912,7 +919,18 @@ async function packageWithContext(context,options={}){
912
919
  }else{
913
920
  await copyBase();
914
921
  }
915
- const files=await listOutputFiles(stagingRoot,{signal});
922
+ let files=await listOutputFiles(stagingRoot,{signal});
923
+ if(moduleFormat==='native'){
924
+ files=await materializeNativeModules({
925
+ stagingRoot,
926
+ files,
927
+ documents:inspected.browserDocuments.map(function documentPath(document){return document.packagePath;}),
928
+ sharedFiles:records.filter(function sharedRecord(record){return record.appRelativePath===undefined;})
929
+ .map(function sharedPath(record){return record.destination;}),
930
+ signal,
931
+ onEvent
932
+ });
933
+ }
916
934
  // Traverse actual browser resources after the adapter finishes. Files
917
935
  // included only as application documents retain their original content.
918
936
  const assetVersion=await readWorkspaceAssetVersion(context.workspaceRoot);
@@ -1114,7 +1132,7 @@ async function packageWithContext(context,options={}){
1114
1132
  }
1115
1133
 
1116
1134
  export async function packageApp(options={}){
1117
- const context=await loadContext(options.workspaceRoot,options.appId);
1135
+ const context=await loadContext(options.workspaceRoot,options.appId,options);
1118
1136
  const execute=()=>packageWithContext(context,options);
1119
1137
  if(options.workspaceOperationLease)return execute();
1120
1138
  return withWorkspaceOperationLock({
@@ -1125,9 +1143,9 @@ export async function packageApp(options={}){
1125
1143
  },execute);
1126
1144
  }
1127
1145
 
1128
- export async function verifyApp({workspaceRoot,appId,signal,onEvent}={}){
1146
+ export async function verifyApp({workspaceRoot,appId,outputDirectory,signal,onEvent}={}){
1129
1147
  throwIfAborted(signal);
1130
- const context=await loadContext(workspaceRoot,appId);
1148
+ const context=await loadContext(workspaceRoot,appId,{outputDirectory});
1131
1149
  const outputRoot=await realDirectory(context.outputRoot,'Package output');
1132
1150
  const manifest=await readJson(path.join(outputRoot,RELEASE_MANIFEST_NAME),RELEASE_MANIFEST_NAME);
1133
1151
  if(!isPlainObject(manifest)||manifest.schemaVersion!==1||manifest.kind!=='arcane-app-release'
@@ -0,0 +1,458 @@
1
+ import Is from 'strong-type';
2
+ import {mkdir,readFile,writeFile} from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ applyNativeReferenceEdits,
6
+ nativeModuleHtml,
7
+ resolveAssetReference,
8
+ rewriteNativeJavaScript,
9
+ rewriteNativeSrcset,
10
+ rewriteNativeStylesheet
11
+ } from '../import-map.mjs';
12
+ import {createHTMLImportScript} from '../../runtime/arcane/modules/HTMLImportScript.js';
13
+
14
+ const is=new Is(false);
15
+ const packageOrigin='https://arcane.invalid/';
16
+ const executableTypes=new Set([
17
+ '', 'module', 'application/ecmascript', 'application/javascript',
18
+ 'application/x-ecmascript', 'application/x-javascript', 'text/ecmascript',
19
+ 'text/javascript', 'text/javascript1.0', 'text/javascript1.1',
20
+ 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
21
+ 'text/javascript1.5', 'text/jscript', 'text/livescript',
22
+ 'text/x-ecmascript', 'text/x-javascript'
23
+ ]);
24
+
25
+ function packageUrl(file){
26
+ return new URL(file.split('/').map(encodeURIComponent).join('/'),packageOrigin);
27
+ }
28
+
29
+ function fileAt(url){
30
+ return url.origin===new URL(packageOrigin).origin
31
+ ?decodeURIComponent(url.pathname).substring(1):null;
32
+ }
33
+
34
+ function relativeUrl(target,base){
35
+ if(target.origin!==base.origin)return target.href;
36
+ const relative=path.posix.relative(new URL('.',base).pathname,target.pathname);
37
+ const pathname=relative==='..'||relative.startsWith('../')?relative:`./${relative}`;
38
+ const trailing=target.pathname.endsWith('/')&&!pathname.endsWith('/')?'/':'';
39
+ return `${pathname}${trailing}${target.search}${target.hash}`;
40
+ }
41
+
42
+ function urlSpecifier(value,base){
43
+ if(!/^(?:\.{1,2}\/|\/|[A-Za-z][A-Za-z0-9+.-]*:)/u.test(value))return null;
44
+ try{return new URL(value,base).href;}
45
+ catch{return null;}
46
+ }
47
+
48
+ function importEntries(entries,base){
49
+ const result=new Map();
50
+ for(const [key,value] of Object.entries(entries??{})){
51
+ const normalized=urlSpecifier(key,base)??key;
52
+ const target=is.string(value)?urlSpecifier(value,base):null;
53
+ result.set(normalized,key.endsWith('/')&&target!==null&&!target.endsWith('/')?null:target);
54
+ }
55
+ return result;
56
+ }
57
+
58
+ function documentMap(source,structure,base){
59
+ const imports=new Map();
60
+ const scopes=new Map();
61
+ for(const script of structure.scripts){
62
+ if(script.attribute('type').trim().toLowerCase()!=='importmap')continue;
63
+ const value=JSON.parse(source.substring(script.openEnd,script.contentEnd));
64
+ for(const [key,target] of importEntries(value.imports,base)){
65
+ if(!imports.has(key))imports.set(key,target);
66
+ }
67
+ for(const [scope,entries] of Object.entries(value.scopes??{})){
68
+ const address=new URL(scope,base).href;
69
+ if(!scopes.has(address))scopes.set(address,new Map());
70
+ const selected=scopes.get(address);
71
+ for(const [key,target] of importEntries(entries,base)){
72
+ if(!selected.has(key))selected.set(key,target);
73
+ }
74
+ }
75
+ }
76
+ return {imports,scopes};
77
+ }
78
+
79
+ function mappedSpecifier(entries,specifier,asUrl){
80
+ if(entries.has(specifier))return {matched:true,target:entries.get(specifier)};
81
+ if(asUrl!==null&&!['http:','https:','file:','ftp:','ws:','wss:'].includes(new URL(asUrl).protocol)){
82
+ return {matched:false};
83
+ }
84
+ const prefixes=[...entries.keys()].filter(function matchingPrefix(key){
85
+ return key.endsWith('/')&&specifier.startsWith(key);
86
+ }).sort(function longestFirst(left,right){return right.length-left.length;});
87
+ if(prefixes.length===0)return {matched:false};
88
+ const prefix=prefixes[0];
89
+ const target=entries.get(prefix);
90
+ const resolved=target===null?null:new URL(specifier.substring(prefix.length),target).href;
91
+ if(resolved!==null&&!resolved.startsWith(target)){
92
+ throw new TypeError(`Import ${specifier} traverses above its import-map prefix ${prefix}.`);
93
+ }
94
+ return {
95
+ matched:true,
96
+ target:resolved
97
+ };
98
+ }
99
+
100
+ function resolveModule(specifier,importer,map){
101
+ const asUrl=urlSpecifier(specifier,importer);
102
+ const normalized=asUrl??specifier;
103
+ const scopes=[...map.scopes.keys()].filter(function applicableScope(scope){
104
+ return importer.href===scope||(scope.endsWith('/')&&importer.href.startsWith(scope));
105
+ }).sort(function mostSpecificFirst(left,right){return right.length-left.length;});
106
+ for(const scope of [...scopes,null]){
107
+ const result=mappedSpecifier(scope===null?map.imports:map.scopes.get(scope),normalized,asUrl);
108
+ if(!result.matched)continue;
109
+ if(result.target===null)throw new TypeError(`Import map blocks ${specifier} in ${importer.pathname}.`);
110
+ return new URL(result.target);
111
+ }
112
+ const address=urlSpecifier(specifier,importer);
113
+ if(address!==null)return new URL(address);
114
+ throw new TypeError(`Native module output cannot resolve ${specifier} in ${importer.pathname}.`);
115
+ }
116
+
117
+ function escapeAttribute(value){
118
+ return value.replaceAll('&','&').replaceAll('"','"')
119
+ .replaceAll("'",'&#39;').replaceAll('<','&lt;').replaceAll('>','&gt;');
120
+ }
121
+
122
+ function setAttribute(element,name,value){
123
+ const position=element.attributes.positions.get(name);
124
+ if(position){
125
+ return {
126
+ start:element.start+position.start,
127
+ end:element.start+position.end,
128
+ value:position.quote?escapeAttribute(value)
129
+ :`${position.assigned?'':'='}"${escapeAttribute(value)}"`
130
+ };
131
+ }
132
+ const close=element.open.lastIndexOf('>');
133
+ const insertion=element.open[close-1]==='/'?close-1:close;
134
+ return {start:element.start+insertion,end:element.start+insertion,value:` ${name}="${escapeAttribute(value)}"`};
135
+ }
136
+
137
+ function scriptPath(file,index,module=false){
138
+ return `${file}.arcane-script-${index}.${module?'mjs':'js'}`;
139
+ }
140
+
141
+ function removeAttribute(element,name){
142
+ const position=element.attributes.positions.get(name);
143
+ return position?[position,...(position.additional??[])].map(function removeOccurrence(item){
144
+ return {
145
+ start:element.start+item.nameEnd-name.length,
146
+ end:element.start+(item.assigned?item.end+(item.quote?1:0):item.nameEnd),
147
+ value:''
148
+ };
149
+ }):[];
150
+ }
151
+
152
+ /** Materialize a native URL graph in the existing package stage, never in source. */
153
+ export async function materializeNativeModules({stagingRoot,files,documents,sharedFiles,signal,onEvent}){
154
+ const inventory=new Set(files);
155
+ const sharedComponents=new Set(sharedFiles.filter(function componentFile(file){
156
+ return /(?:^|\/)components\/.*\.html?$/iu.test(file);
157
+ }));
158
+ const sourceReads=new Map();
159
+ const nodes=[];
160
+ const contexts=[];
161
+ const nodeIndex=new Map();
162
+
163
+ async function readSource(file){
164
+ if(!sourceReads.has(file)){
165
+ sourceReads.set(file,readFile(path.join(stagingRoot,...file.split('/')),'utf8'));
166
+ }
167
+ return sourceReads.get(file);
168
+ }
169
+
170
+ function getNode(kind,address,context,{source,sourceKey,destination,importer}={}){
171
+ const file=fileAt(address);
172
+ if(file===null||(!inventory.has(file)&&source===undefined))return null;
173
+ const key=`${context.id}\n${kind}\n${sourceKey??address.href}`;
174
+ if(nodeIndex.has(key))return nodeIndex.get(key);
175
+ const node={
176
+ id:nodes.length,kind,address,context,file,source,
177
+ sourceKey:`${kind}\n${sourceKey??file}`,
178
+ desired:destination??file,
179
+ destination:destination??file,
180
+ importer:importer??address,
181
+ edges:[]
182
+ };
183
+ nodes.push(node);
184
+ nodeIndex.set(key,node);
185
+ return node;
186
+ }
187
+
188
+ for(const file of [...new Set(documents)]){
189
+ signal?.throwIfAborted();
190
+ const source=await readSource(file);
191
+ const structure=nativeModuleHtml(source);
192
+ const address=packageUrl(file);
193
+ const baseElement=structure.bases.find(function firstHref(element){return element.attributes.has('href');});
194
+ const base=baseElement?new URL(baseElement.attribute('href'),address):address;
195
+ const context={id:contexts.length,address,base,map:documentMap(source,structure,base)};
196
+ contexts.push(context);
197
+ getNode('document',address,context,{source});
198
+ }
199
+ if(contexts.length===0)return files;
200
+
201
+ function resourceEdge(node,reference){
202
+ const module=reference.kind==='import'||reference.kind==='import-resolve';
203
+ const documentBase=node.kind==='worker'?node.address:node.context.base;
204
+ const target=module?resolveModule(reference.url,node.importer,node.context.map)
205
+ :resolveAssetReference(reference,{
206
+ ownerUrl:node.importer,
207
+ documentUrl:documentBase
208
+ });
209
+ let kind=null;
210
+ if(module||reference.kind==='script')kind=reference.kind==='script'?'worker':'script';
211
+ if(reference.kind==='component')kind='component';
212
+ if(['asset','fetch'].includes(reference.kind)&&sharedComponents.has(fileAt(target))){
213
+ kind='component';
214
+ }
215
+ if(reference.kind==='style'||/\.css(?:[?#]|$)/iu.test(target.href))kind='style';
216
+ const dependency=kind?getNode(kind,target,node.context):null;
217
+ return {
218
+ target,dependency,
219
+ baseKind:module||node.kind==='worker'?undefined:reference.baseKind
220
+ };
221
+ }
222
+
223
+ function edgeUrl(node,edge){
224
+ const target=new URL(edge.target);
225
+ if(edge.dependency){
226
+ target.pathname=packageUrl(edge.dependency.destination).pathname;
227
+ }
228
+ const base=edge.baseKind==='document'?node.context.base:packageUrl(node.destination);
229
+ return relativeUrl(target,base);
230
+ }
231
+
232
+ function htmlRenderer(node,source){
233
+ const structure=nativeModuleHtml(source);
234
+ const component=node.kind==='component';
235
+ const scriptElements=new Set(structure.scripts.map(function scriptStart(script){return script.start;}));
236
+ const scriptPlans=[];
237
+ for(const [index,script] of structure.scripts.entries()){
238
+ const type=script.attribute('type').trim().toLowerCase();
239
+ if(type==='importmap'){
240
+ scriptPlans.push({script,remove:true});
241
+ continue;
242
+ }
243
+ if(!executableTypes.has(type))continue;
244
+ const src=script.attribute('src');
245
+ const hasSource=script.attributes.has('src');
246
+ if(!component&&hasSource){
247
+ scriptPlans.push({script,reference:{url:src,kind:'document-script',baseKind:'document'}});
248
+ continue;
249
+ }
250
+ if(!component&&node.context.base.origin!==new URL(packageOrigin).origin){
251
+ throw new TypeError(
252
+ `Native module output cannot externalize an inline script under a remote document base: ${node.file}`
253
+ );
254
+ }
255
+ const sourceAddress=hasSource?resolveAssetReference({
256
+ url:src,
257
+ baseKind:src.startsWith('./arcane/')?'component-runtime':undefined
258
+ },{ownerUrl:node.address,documentUrl:node.context.base})
259
+ :component?node.address:node.context.base;
260
+ const sourceFile=fileAt(sourceAddress);
261
+ scriptPlans.push({
262
+ script,index,sourceAddress,sourceFile,
263
+ body:hasSource?null:source.substring(script.openEnd,script.contentEnd),
264
+ module:type==='module'
265
+ });
266
+ }
267
+ return {structure,scriptPlans,scriptElements,component};
268
+ }
269
+
270
+ for(let index=0;index<nodes.length;index+=1){
271
+ signal?.throwIfAborted();
272
+ const node=nodes[index];
273
+ node.source??=await readSource(node.file);
274
+ if(node.kind==='script'||node.kind==='worker'){
275
+ node.render=function renderScript(reference){return rewriteNativeJavaScript(node.source,reference);};
276
+ }else if(node.kind==='style'){
277
+ node.render=function renderStyle(reference){return rewriteNativeStylesheet(node.source,reference);};
278
+ }else{
279
+ const {structure,scriptPlans,scriptElements,component}=htmlRenderer(node,node.source);
280
+ for(const plan of scriptPlans){
281
+ if(plan.remove||plan.reference)continue;
282
+ if(plan.body===null){
283
+ if(plan.sourceFile===null||!inventory.has(plan.sourceFile)){
284
+ throw new TypeError(`Packaged component script is unavailable: ${plan.sourceAddress.href}`);
285
+ }
286
+ plan.body=await readSource(plan.sourceFile);
287
+ }
288
+ plan.dependency=getNode('script',node.address,node.context,{
289
+ source:component?createHTMLImportScript(plan.body):plan.body,
290
+ sourceKey:`${node.kind}:${node.file}#script-${plan.index}`,
291
+ destination:scriptPath(node.file,plan.index,!component&&plan.module),
292
+ importer:plan.sourceAddress
293
+ });
294
+ }
295
+ node.render=function renderHtml(reference){
296
+ const edits=[];
297
+ for(const plan of scriptPlans){
298
+ const {script}=plan;
299
+ if(plan.remove){
300
+ edits.push({start:script.start,end:script.end,value:''});
301
+ continue;
302
+ }
303
+ let url;
304
+ if(plan.reference){
305
+ url=reference(plan.reference);
306
+ }else{
307
+ url=reference({
308
+ url:plan.sourceAddress.href,
309
+ kind:'generated-script',
310
+ dependency:plan.dependency,
311
+ baseKind:component?undefined:'document'
312
+ });
313
+ edits.push({start:script.openEnd,end:script.contentEnd,value:''});
314
+ }
315
+ edits.push(setAttribute(script,'src',url));
316
+ if(component)edits.push(setAttribute(script,'data-arcane-packaged-script',''));
317
+ else if(!plan.reference&&!plan.module){
318
+ // Inline classic bodies are parser-blocking even with these attributes.
319
+ // Their external form must retain that effective scheduling.
320
+ for(const name of ['async','defer']){
321
+ edits.push(...removeAttribute(script,name));
322
+ }
323
+ }
324
+ }
325
+ for(const element of structure.elements){
326
+ if(scriptElements.has(element.start))continue;
327
+ if(element.tag==='base'&&element.attributes.has('href')){
328
+ const target=new URL(element.attribute('href'),node.address);
329
+ edits.push(setAttribute(element,'href',relativeUrl(target,packageUrl(node.destination))));
330
+ continue;
331
+ }
332
+ const attributes=[];
333
+ if(element.tag==='html-import')attributes.push(['href','component']);
334
+ else if(['img','audio','video','source','track','iframe','embed','input'].includes(element.tag)){
335
+ attributes.push(['src','asset']);
336
+ }
337
+ if(element.tag==='video')attributes.push(['poster','asset']);
338
+ if(element.tag==='object')attributes.push(['data','asset']);
339
+ if(element.tag==='link'){
340
+ const relationships=element.attribute('rel').trim().toLowerCase().split(/\s+/u);
341
+ const kind=relationships.includes('stylesheet')?'style'
342
+ :relationships.includes('modulepreload')?'document-script':'asset';
343
+ attributes.push(['href',kind]);
344
+ }
345
+ if(['img','source'].includes(element.tag)&&element.attributes.has('srcset')){
346
+ const original=element.attribute('srcset');
347
+ const value=rewriteNativeSrcset(original,function nativeSrcsetReference(item){
348
+ return reference({...item,baseKind:'document'});
349
+ });
350
+ if(value!==original)edits.push(setAttribute(element,'srcset',value));
351
+ }
352
+ if(element.attributes.has('style')){
353
+ const original=element.attribute('style');
354
+ const value=rewriteNativeStylesheet(original,function inlineAttributeStyle(item){
355
+ return reference({...item,baseKind:'document'});
356
+ });
357
+ if(value!==original)edits.push(setAttribute(element,'style',value));
358
+ }
359
+ for(const [attribute,kind] of attributes){
360
+ if(!element.attributes.has(attribute))continue;
361
+ const url=element.attribute(attribute);
362
+ const value=reference({
363
+ url,kind,
364
+ baseKind:component&&url.startsWith('./arcane/')?'component-runtime':'document'
365
+ });
366
+ if(value!==url)edits.push(setAttribute(element,attribute,value));
367
+ }
368
+ }
369
+ for(const style of structure.styles){
370
+ const body=node.source.substring(style.start,style.end);
371
+ const content=rewriteNativeStylesheet(body,function inlineStyleReference(value){
372
+ return reference({
373
+ ...value,
374
+ baseKind:component&&value.url.startsWith('./arcane/')?'component-runtime':'document'
375
+ });
376
+ });
377
+ if(content!==body)edits.push({start:style.start,end:style.end,value:content});
378
+ }
379
+ return applyNativeReferenceEdits(node.source,edits);
380
+ };
381
+ }
382
+ node.render(function collectReference(reference){
383
+ let edge;
384
+ if(reference.dependency){
385
+ edge={target:new URL(reference.url),dependency:reference.dependency,baseKind:reference.baseKind};
386
+ }else if(reference.kind==='document-script'){
387
+ const target=new URL(reference.url,node.context.base);
388
+ edge={target,dependency:getNode('script',target,node.context),baseKind:'document'};
389
+ }else{
390
+ edge=resourceEdge(node,reference);
391
+ // Fragment attributes are attached to the host document, not the fetched HTML.
392
+ if(reference.baseKind==='component-runtime')edge.baseKind='document';
393
+ }
394
+ node.edges.push(edge);
395
+ return reference.url;
396
+ });
397
+ }
398
+
399
+ // Refine graph equivalence, including cycles, before assigning output paths.
400
+ // Documents with different maps share files only when their resolved graphs agree.
401
+ let groups=[];
402
+ const initial=new Map();
403
+ for(const node of nodes){
404
+ if(!initial.has(node.sourceKey))initial.set(node.sourceKey,initial.size);
405
+ node.group=initial.get(node.sourceKey);
406
+ }
407
+ let changed=true;
408
+ while(changed){
409
+ const partitions=new Map();
410
+ groups=[];
411
+ const next=[];
412
+ for(const node of nodes){
413
+ const signature=JSON.stringify([
414
+ node.group,
415
+ node.edges.map(function edgeSignature(edge){
416
+ return [edge.dependency?.group??null,edge.target.href,edge.baseKind,
417
+ edge.baseKind==='document'?node.context.base.href:null];
418
+ })
419
+ ]);
420
+ if(!partitions.has(signature)){
421
+ partitions.set(signature,groups.length);
422
+ groups.push([]);
423
+ }
424
+ const group=partitions.get(signature);
425
+ groups[group].push(node);
426
+ next.push(group);
427
+ }
428
+ changed=nodes.some(function partitionChanged(node,index){return node.group!==next[index];});
429
+ for(const [index,node] of nodes.entries())node.group=next[index];
430
+ }
431
+
432
+ const assigned=new Set();
433
+ for(const group of groups){
434
+ const node=group[0];
435
+ let destination=node.desired;
436
+ let variant=1;
437
+ while(assigned.has(destination)||(inventory.has(destination)&&destination!==node.file)){
438
+ const extension=path.posix.extname(node.desired);
439
+ const stem=node.desired.substring(0,node.desired.length-extension.length);
440
+ destination=`${stem}.arcane-context-${variant++}${extension}`;
441
+ }
442
+ assigned.add(destination);
443
+ for(const member of group)member.destination=destination;
444
+ }
445
+
446
+ for(const group of groups){
447
+ signal?.throwIfAborted();
448
+ const node=group[0];
449
+ let index=0;
450
+ const content=node.render(function nativeReference(){return edgeUrl(node,node.edges[index++]);});
451
+ const destination=path.join(stagingRoot,...node.destination.split('/'));
452
+ await mkdir(path.dirname(destination),{recursive:true});
453
+ await writeFile(destination,content,'utf8');
454
+ inventory.add(node.destination);
455
+ if(is.function(onEvent))await onEvent({type:'package.native-module.written',path:node.destination});
456
+ }
457
+ return [...inventory].sort();
458
+ }
@@ -15,6 +15,7 @@ const MANAGED_IMPORT_MAP_BOUNDARIES=new Set(['source','dist','test']);
15
15
 
16
16
  let managedImports=null;
17
17
  let managedUrlImports=null;
18
+ let managedScopes=[];
18
19
 
19
20
  function loaderFailure(message,code='ARCANE_IMPORT_MAP_INVALID'){
20
21
  const error=new Error(message);
@@ -109,19 +110,7 @@ function physicalManagedTarget(target,specifier,boundary,boundaryName){
109
110
  return targetURL.href;
110
111
  }
111
112
 
112
- export function initialize({managedImportMap}={}){
113
- if(managedImportMap==null){
114
- managedImports=null;
115
- managedUrlImports=null;
116
- return;
117
- }
118
- const imports=managedImportMap.imports;
119
- if(managedImportMap.protocol!==MANAGED_IMPORT_MAP_PROTOCOL
120
- ||!MANAGED_IMPORT_MAP_BOUNDARIES.has(managedImportMap.boundary)
121
- ||imports===null||!is.object(imports)||is.array(imports)){
122
- loaderFailure('Managed test import-map loader data is malformed.');
123
- }
124
- const boundary=physicalDirectory(managedImportMap.baseURL);
113
+ function managedBindings(imports,boundary,boundaryName){
125
114
  const exact=new Map();
126
115
  const urls=new Map();
127
116
  for(const [specifier,target] of Object.entries(imports)){
@@ -132,7 +121,7 @@ export function initialize({managedImportMap}={}){
132
121
  target,
133
122
  specifier,
134
123
  boundary,
135
- managedImportMap.boundary
124
+ boundaryName
136
125
  );
137
126
  exact.set(specifier,targetURL);
138
127
  if(urlLikeSpecifier(specifier)){
@@ -145,20 +134,52 @@ export function initialize({managedImportMap}={}){
145
134
  urls.set(keyURL,targetURL);
146
135
  }
147
136
  }
148
- managedImports=exact;
149
- managedUrlImports=urls;
137
+ return {exact,urls};
138
+ }
139
+
140
+ export function initialize({managedImportMap}={}){
141
+ if(managedImportMap==null){
142
+ managedImports=null;
143
+ managedUrlImports=null;
144
+ managedScopes=[];
145
+ return;
146
+ }
147
+ const imports=managedImportMap.imports;
148
+ if(managedImportMap.protocol!==MANAGED_IMPORT_MAP_PROTOCOL
149
+ ||!MANAGED_IMPORT_MAP_BOUNDARIES.has(managedImportMap.boundary)
150
+ ||imports===null||!is.object(imports)||is.array(imports)){
151
+ loaderFailure('Managed test import-map loader data is malformed.');
152
+ }
153
+ const boundary=physicalDirectory(managedImportMap.baseURL);
154
+ const bindings=managedBindings(imports,boundary,managedImportMap.boundary);
155
+ const scopes=Object.entries(managedImportMap.scopes??{}).map(([scope,entries])=>({
156
+ url:new URL(scope,boundary.url).href,
157
+ ...managedBindings(entries,boundary,managedImportMap.boundary)
158
+ })).sort((left,right)=>right.url.length-left.url.length);
159
+ managedImports=bindings.exact;
160
+ managedUrlImports=bindings.urls;
161
+ managedScopes=scopes;
150
162
  }
151
163
 
152
164
  function managedResolution(specifier,context){
153
165
  if(managedImports===null)return null;
154
- if(managedImports.has(specifier))return managedImports.get(specifier);
166
+ let requested=null;
155
167
  if(urlLikeSpecifier(specifier)&&is.string(context.parentURL)){
156
- let requested;
157
168
  try{requested=new URL(specifier,context.parentURL).href;}
158
169
  catch{requested=null;}
159
- if(requested!==null&&managedUrlImports.has(requested)){
160
- return managedUrlImports.get(requested);
170
+ }
171
+ // Match the browser's most-specific dependency scope before global imports.
172
+ for(const scope of managedScopes){
173
+ if(context.parentURL!==scope.url
174
+ &&!(scope.url.endsWith('/')&&context.parentURL?.startsWith(scope.url))){
175
+ continue;
161
176
  }
177
+ if(scope.exact.has(specifier))return scope.exact.get(specifier);
178
+ if(requested!==null&&scope.urls.has(requested))return scope.urls.get(requested);
179
+ }
180
+ if(managedImports.has(specifier))return managedImports.get(specifier);
181
+ if(requested!==null&&managedUrlImports.has(requested)){
182
+ return managedUrlImports.get(requested);
162
183
  }
163
184
  if(specifier.startsWith('arcane/')||specifier.startsWith('#arcane/')){
164
185
  loaderFailure(