arcane-os 0.26.0 → 0.27.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.
@@ -6,6 +6,7 @@ import https from 'node:https';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import {resolveWorkspace} from './workspace.mjs';
9
+ import {appRelativeRoot} from './app-layout.mjs';
9
10
  import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
10
11
  import {APP_DESCRIPTOR_NAME, projectPackageManifest} from './app-descriptor.mjs';
11
12
  import {APP_CONFIG_NAME, validateAppConfig} from './packager/core.mjs';
@@ -97,7 +98,7 @@ function parseRequestTarget(rawUrl){
97
98
  let parsed;
98
99
  try{parsed=new URL(raw,'http://127.0.0.1');}
99
100
  catch{return null;}
100
- return {segments,path:decoded,searchParams:parsed.searchParams};
101
+ return {segments,path:decoded,pathname:parsed.pathname,search:parsed.search,searchParams:parsed.searchParams};
101
102
  }
102
103
 
103
104
  function resolveInside(root,segments){
@@ -450,7 +451,8 @@ async function sourceRoutes(workspaceRoot,appId,{
450
451
  }){
451
452
  const resolved=await resolveWorkspace({workspaceRoot,appId});
452
453
  const appMapping={
453
- prefix:['apps',resolved.appId],
454
+ kind:'app',
455
+ prefix:appRelativeRoot(resolved.config,resolved.appId).split('/').filter(Boolean),
454
456
  root:resolved.appRoot,
455
457
  include:resolved.app.manifest.include,
456
458
  allow:relative=>sourcePathAllowed(relative,resolved.app.manifest)
@@ -462,15 +464,24 @@ async function sourceRoutes(workspaceRoot,appId,{
462
464
  resolved.appId,
463
465
  {signal,onEvent}
464
466
  );
467
+ const installedRoutes=resolved.config.browserRuntimeLayout==='installed-v1'
468
+ ?resolved.config.sharedPayloads['browser-runtime']:null;
469
+ const sourceMappings=installedRoutes?sdkSource.mappings.map(
470
+ function selectLiveSourceDestination(mapping,index){
471
+ const route=installedRoutes[[0,2,1][index]];
472
+ return {...mapping,prefix:route.destination.split('/')};
473
+ }
474
+ ):sdkSource.mappings;
465
475
  return {
466
476
  workspaceRoot:resolved.workspaceRoot,
467
477
  workspaceMode:resolved.config.workspaceMode,
468
478
  config:resolved.config,
469
479
  appId:resolved.appId,
470
480
  app:resolved.app.manifest,
471
- startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
481
+ startPath:applicationSourcePath(resolved.config,resolved.appId,resolved.app.manifest.entry),
472
482
  runtime:sdkSource.runtime,
473
- mappings:[appMapping,...sdkSource.mappings]
483
+ ...(installedRoutes?{browserRuntimeBase:`/${installedRoutes[1].destination}/`}:{}),
484
+ mappings:[appMapping,...sourceMappings]
474
485
  };
475
486
  }
476
487
  if(resolved.config.workspaceMode==='integrated'
@@ -485,7 +496,7 @@ async function sourceRoutes(workspaceRoot,appId,{
485
496
  ...(installed?{installed}:{}),
486
497
  appId:resolved.appId,
487
498
  app:resolved.app.manifest,
488
- startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
499
+ startPath:applicationSourcePath(resolved.config,resolved.appId,resolved.app.manifest.entry),
489
500
  mappings:[
490
501
  appMapping,
491
502
  ...resolved.config.sharedPayloads['browser-runtime'].map(route=>({
@@ -504,7 +515,7 @@ async function sourceRoutes(workspaceRoot,appId,{
504
515
  config:resolved.config,
505
516
  appId:resolved.appId,
506
517
  app:resolved.app.manifest,
507
- startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
518
+ startPath:applicationSourcePath(resolved.config,resolved.appId,resolved.app.manifest.entry),
508
519
  mappings:[
509
520
  appMapping,
510
521
  {
@@ -517,6 +528,11 @@ async function sourceRoutes(workspaceRoot,appId,{
517
528
  };
518
529
  }
519
530
 
531
+ function applicationSourcePath(config,appId,relative='') {
532
+ const root=appRelativeRoot(config,appId);
533
+ return `/${root?`${root}/`:''}${relative}`;
534
+ }
535
+
520
536
  async function packagedRoutes(releaseRoot){
521
537
  if(!is.string(releaseRoot)||!releaseRoot.trim())fail('releaseRoot is required in packaged mode.','ARCANE_USAGE');
522
538
  const requested=path.resolve(releaseRoot);
@@ -578,8 +594,7 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
578
594
  } else if (info.isFile()) {
579
595
  const segments = [...mapping.prefix, ...relative];
580
596
  const url = `/${segments.map(encodeURIComponent).join('/')}`;
581
- const appResource = mapping.prefix[0] === 'apps'
582
- && mapping.prefix[1] === routeSet.appId;
597
+ const appResource = mapping.kind === 'app';
583
598
  const logical = (appResource ? relative : segments).join('/');
584
599
  records.set(logical, url);
585
600
  resources.set(url, {mapping, relative});
@@ -597,7 +612,7 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
597
612
  for (const selected of routeSet.app.include) {
598
613
  if (!/\.html?$/iu.test(selected)) continue;
599
614
  const url = new URL(
600
- `/apps/${routeSet.appId}/${selected.split('/').map(encodeURIComponent).join('/')}`,
615
+ applicationSourcePath(routeSet.config,routeSet.appId,selected.split('/').map(encodeURIComponent).join('/')),
601
616
  origin
602
617
  );
603
618
  pending.push({url, documentUrl: url});
@@ -636,8 +651,8 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
636
651
  const documentUrl = authoredBase ? new URL(authoredBase, current.url) : current.documentUrl;
637
652
  if (!runtimeRootsAdded && pathname === entryUrl.pathname) {
638
653
  runtimeRootsAdded = true;
639
- for (const url of resources.keys()) {
640
- if (/^\/arcane\/.*\.(?:m?js|html?|css)$/iu.test(url)
654
+ for (const [url, resource] of resources) {
655
+ if ((resource.mapping.kind !== 'app' && /\.(?:m?js|html?|css)$/iu.test(url))
641
656
  || path.posix.basename(url) === 'arcane.importmap.json') {
642
657
  pending.push({url: new URL(url, origin), documentUrl});
643
658
  }
@@ -966,7 +981,7 @@ async function startOwnedDevServer({
966
981
  let sourceManifestTask;
967
982
  const appMapping = mappings.find(
968
983
  function selectedApplicationMapping(mapping) {
969
- return mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId;
984
+ return mapping.kind === 'app';
970
985
  }
971
986
  );
972
987
  async function refreshSourceRoutes() {
@@ -1017,7 +1032,7 @@ async function startOwnedDevServer({
1017
1032
  currentSourceRoutes = {
1018
1033
  ...routeSet,
1019
1034
  app: manifest,
1020
- startPath: `/apps/${routeSet.appId}/${manifest.entry}`,
1035
+ startPath: applicationSourcePath(routeSet.config,routeSet.appId,manifest.entry),
1021
1036
  mappings: mappings.map(
1022
1037
  function currentSourceMapping(mapping) {
1023
1038
  return mapping === appMapping ? currentAppMapping : mapping;
@@ -1031,6 +1046,26 @@ async function startOwnedDevServer({
1031
1046
  let currentPwaState;
1032
1047
  const pwaResourceUrls = new Set();
1033
1048
  function developmentPwaArtifacts(selectedRoutes, assets = [], version = assetVersion) {
1049
+ const rootApp = selectedRoutes.config.appsRoot === '.';
1050
+ const appBase = applicationSourcePath(selectedRoutes.config,selectedRoutes.appId);
1051
+ const navigationAliases = {'/': selectedRoutes.startPath};
1052
+ if (rootApp) {
1053
+ const legacyBase = `/apps/${selectedRoutes.appId}`;
1054
+ navigationAliases[legacyBase] = selectedRoutes.startPath;
1055
+ navigationAliases[`${legacyBase}/`] = selectedRoutes.startPath;
1056
+ for (const asset of [selectedRoutes.startPath,...assets]) {
1057
+ const pathname = new URL(asset,'http://arcane.invalid').pathname;
1058
+ if (!/\.html?$/iu.test(pathname)) continue;
1059
+ const segments = decodeURIComponent(pathname).split('/').filter(Boolean);
1060
+ const mapping = selectedRoutes.mappings.find(function matchingNavigationRoute(route) {
1061
+ return route.prefix.every(function matchingNavigationSegment(segment,index) {
1062
+ return segments[index] === segment;
1063
+ });
1064
+ });
1065
+ if (mapping?.kind === 'app') navigationAliases[`${legacyBase}${pathname}`] = pathname;
1066
+ }
1067
+ navigationAliases[`${legacyBase}/index.html`] = selectedRoutes.startPath;
1068
+ }
1034
1069
  return createPwaArtifacts(
1035
1070
  {
1036
1071
  app: {
@@ -1043,10 +1078,12 @@ async function startOwnedDevServer({
1043
1078
  pwa: selectedRoutes.app.pwa,
1044
1079
  files: [],
1045
1080
  assets,
1046
- navigationAliases: {'/': selectedRoutes.startPath},
1081
+ navigationAliases,
1047
1082
  basePath: '/',
1048
- appBase: `/apps/${routeSet.appId}/`,
1049
- runtimeBase: '/arcane/sdk/',
1083
+ appBase,
1084
+ ...(rootApp ? {installationId:`/apps/${routeSet.appId}/`} : {}),
1085
+ runtimeBase: selectedRoutes.browserRuntimeBase
1086
+ ?? selectedRoutes.installed?.browserRuntimeBase ?? '/arcane/sdk/',
1050
1087
  mode: 'development'
1051
1088
  }
1052
1089
  );
@@ -1138,10 +1175,25 @@ async function startOwnedDevServer({
1138
1175
  const {segments}=target;
1139
1176
  const generatedPwaPath = ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs']
1140
1177
  .includes(target.path);
1141
- const appRequest = segments[0] === 'apps' && segments[1] === routeSet.appId;
1178
+ const legacyAppRequest = mode === 'source' && routeSet.config.appsRoot === '.'
1179
+ && segments[0] === 'apps' && segments[1] === routeSet.appId;
1180
+ const requestedMapping = currentSourceRoutes.mappings.find(function currentRequestMapping(route) {
1181
+ return route.prefix.every(function currentRequestSegment(segment,index) {
1182
+ return segments[index] === segment;
1183
+ });
1184
+ });
1185
+ const appRequest = requestedMapping?.kind === 'app' || legacyAppRequest;
1142
1186
  const selectedRoutes = mode === 'source' && (appRequest || segments.length === 0 || generatedPwaPath)
1143
1187
  ? await refreshSourceRoutes() : currentSourceRoutes;
1144
1188
  const pwaEnabled = mode === 'source' ? selectedRoutes.app?.pwa?.enabled === true : routeSet.pwa;
1189
+ if (legacyAppRequest) {
1190
+ const legacyPath = target.pathname.slice(`/apps/${routeSet.appId}`.length);
1191
+ const location = !legacyPath || legacyPath === '/' || legacyPath === '/index.html'
1192
+ ? selectedRoutes.startPath : legacyPath;
1193
+ response.writeHead(302,{location:`${location}${target.search}`});
1194
+ response.end();
1195
+ return;
1196
+ }
1145
1197
  if (mode === 'source' && pwaEnabled
1146
1198
  && generatedPwaPath) {
1147
1199
  const generated = await sourcePwaArtifact(target.path, selectedRoutes);
@@ -1163,7 +1215,7 @@ async function startOwnedDevServer({
1163
1215
  return;
1164
1216
  }
1165
1217
  if(segments.length===0){
1166
- response.writeHead(302,{location:selectedRoutes.startPath});
1218
+ response.writeHead(302,{location:`${selectedRoutes.startPath}${target.search}`});
1167
1219
  response.end();
1168
1220
  return;
1169
1221
  }
@@ -1195,7 +1247,7 @@ async function startOwnedDevServer({
1195
1247
  const extension=path.extname(opened.candidate).toLowerCase();
1196
1248
  const html=extension==='.html'||extension==='.htm';
1197
1249
  const selectedPwaDocument = mode === 'source' && pwaEnabled && html
1198
- && mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId
1250
+ && mapping.kind === 'app'
1199
1251
  && selectedRoutes.app.include.includes(relative.join('/'));
1200
1252
  const selectedDocument = target.path === selectedRoutes.startPath || selectedPwaDocument;
1201
1253
  const managedDocument = !selectedDocument && html && await isManagedDocument(opened);
@@ -1204,7 +1256,7 @@ async function startOwnedDevServer({
1204
1256
  // A live server can span an SDK upgrade. Refresh the small
1205
1257
  // version record on navigation, not on each resource request.
1206
1258
  if(entryDocument||managedMap)rememberAssetVersion(await selectedAssetVersion());
1207
- const runtimeResource=segments[0]==='arcane';
1259
+ const runtimeResource=mapping.kind!=='app';
1208
1260
  const browserResource=['script','style','worker','sharedworker','serviceworker']
1209
1261
  .includes(request.headers['sec-fetch-dest']);
1210
1262
  const rewrite=runtimeResource||entryDocument||browserResource||managedMap
@@ -1,4 +1,5 @@
1
1
  import Is from 'strong-type';
2
+ import {appBaseHref,resolveAppRoot} from './app-layout.mjs';
2
3
  import {lstat,mkdir,readFile as readFileFromDisk,readdir,realpath,writeFile} from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import {pathToFileURL} from 'node:url';
@@ -1459,6 +1460,20 @@ async function managedImportMapBuild(resolvedWorkspace,signal,pwaEnabled=false){
1459
1460
  pwaEnabled?null:installed?.version??readWorkspaceAssetVersion(resolvedWorkspace)
1460
1461
  ]);
1461
1462
  const built=await buildImportMap({files:runtime.files,signal,version});
1463
+ if(installed?.direct){
1464
+ const imports={};
1465
+ function installedBrowserUrl(value){
1466
+ return value.startsWith('./')
1467
+ ?`./${installedRuntimeTarget(value.slice(2),installed,{browser:true})}`:value;
1468
+ }
1469
+ for(const [specifier,target] of Object.entries(built.imports)){
1470
+ const selected=installedBrowserUrl(target);
1471
+ imports[specifier]=selected;
1472
+ // Relative module imports and bare names must resolve to the same instance.
1473
+ imports[installedBrowserUrl(specifier)]=selected;
1474
+ }
1475
+ built.imports=imports;
1476
+ }
1462
1477
  const json=`${JSON.stringify({imports:built.imports},null,2).replaceAll('<','\\u003c')}\n`;
1463
1478
  return {built,json,version};
1464
1479
  }
@@ -2178,12 +2193,6 @@ export function inspectImportMapHtml(html){
2178
2193
  };
2179
2194
  }
2180
2195
 
2181
- function documentBaseHref(relative){
2182
- const directory=path.posix.dirname(relative);
2183
- const depth=directory==='.'?0:directory.split('/').length;
2184
- return '../'.repeat(depth+2);
2185
- }
2186
-
2187
2196
  function renderManagedHtml(html,json,baseHref='../../'){
2188
2197
  const structure=scanHtmlStructure(html);
2189
2198
  const activeBases=structure.bases.map(base=>({
@@ -2340,11 +2349,18 @@ async function writeGeneratedFiles({root,files,signal,onEvent}){
2340
2349
  return eventError;
2341
2350
  }
2342
2351
 
2343
- function resolvedAppRoot(workspaceRoot,appId,appRoot){
2352
+ async function resolvedAppRoot(workspaceRoot,appId,appRoot){
2344
2353
  if(!is.string(appId)||appId.trim()===''){
2345
2354
  throw new TypeError('Import-map app id must be a nonempty string.');
2346
2355
  }
2347
- const resolved=path.resolve(appRoot??path.join(workspaceRoot,'apps',appId));
2356
+ let selected=appRoot;
2357
+ if(selected===undefined){
2358
+ let config={appsRoot:'apps'};
2359
+ try{config=JSON.parse(await readFileFromDisk(path.join(workspaceRoot,'arcane-packager.json'),'utf8'));}
2360
+ catch(error){if(error?.code!=='ENOENT')throw error;}
2361
+ selected=resolveAppRoot(workspaceRoot,config,appId);
2362
+ }
2363
+ const resolved=path.resolve(selected);
2348
2364
  if(!pathInside(workspaceRoot,resolved))fail('Import-map application root must stay inside the workspace.');
2349
2365
  return resolved;
2350
2366
  }
@@ -2789,7 +2805,7 @@ async function generateImportMapUnlocked({
2789
2805
  }
2790
2806
  throwIfAborted(signal);
2791
2807
  const resolvedWorkspace=path.resolve(workspaceRoot);
2792
- const resolvedApp=resolvedAppRoot(resolvedWorkspace,appId,appRoot);
2808
+ const resolvedApp=await resolvedAppRoot(resolvedWorkspace,appId,appRoot);
2793
2809
  await physicalDirectory(resolvedWorkspace,resolvedApp);
2794
2810
  const safeEntry=safeRelativePath(entry,'application entry');
2795
2811
  const safeDocuments=normalizedDocumentPaths(safeEntry,documents);
@@ -2819,7 +2835,7 @@ async function generateImportMapUnlocked({
2819
2835
  const html=await readPhysicalTextFile(resolvedApp,documentPath,label);
2820
2836
  throwIfAborted(signal);
2821
2837
  // Reject malformed application structure before traversing the runtime inventory.
2822
- const baseHref=documentBaseHref(safeDocuments[index]);
2838
+ const baseHref=appBaseHref(resolvedWorkspace,resolvedApp,safeDocuments[index]);
2823
2839
  renderManagedHtml(html,'{"imports":{}}\n',baseHref);
2824
2840
  documentStates.push({filePath:documentPath,html,label,baseHref});
2825
2841
  }
@@ -12,6 +12,8 @@ import {
12
12
  } from 'node:fs/promises';
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
+ import {appRelativeRoot,resolveAppRoot,rootAppNavigation} from '../app-layout.mjs';
16
+ import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
15
17
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
16
18
  import {
17
19
  applyPwaEntryReferences,
@@ -275,8 +277,8 @@ function validateSharedRoute(route,label){
275
277
  export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
276
278
  assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads']),ROOT_CONFIG_NAME);
277
279
  if(value.schemaVersion!==1)fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
278
- if(value.appsRoot!=='apps'||value.distRoot!=='dist'){
279
- fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" and distRoot to "dist".`);
280
+ if(!['apps','.'].includes(value.appsRoot)||value.distRoot!=='dist'){
281
+ fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" or "." and distRoot to "dist".`);
280
282
  }
281
283
  if(!isPlainObject(value.sharedPayloads)){
282
284
  fail(`${ROOT_CONFIG_NAME}.sharedPayloads must be an object.`);
@@ -291,7 +293,7 @@ export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
291
293
  validateSharedRoute(route,`sharedPayloads.${id}[${index}]`)
292
294
  );
293
295
  }
294
- return {schemaVersion:1,appsRoot:'apps',distRoot:'dist',sharedPayloads,configPath};
296
+ return {schemaVersion:1,appsRoot:value.appsRoot,distRoot:'dist',sharedPayloads,configPath};
295
297
  }
296
298
 
297
299
  function normalizeOptionalRecord(value,label){
@@ -300,14 +302,14 @@ function normalizeOptionalRecord(value,label){
300
302
  return copyJson(value);
301
303
  }
302
304
 
303
- export function validateAppConfig(value,appId,rootConfig,configPath=`apps/${appId}/${APP_CONFIG_NAME}`){
305
+ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
304
306
  assertOnlyKeys(value,new Set([
305
307
  'schemaVersion','id','displayName','version','entry','strategy','security',
306
308
  'localAIModelPolicy','include','exclude','shared','adapter','pwa'
307
309
  ]),`${appId}/${APP_CONFIG_NAME}`);
308
310
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
309
- if(value.id!==appId||!APP_ID_PATTERN.test(value.id)){
310
- fail(`${appId}/${APP_CONFIG_NAME}.id must exactly match its apps directory.`);
311
+ if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
312
+ fail(`${APP_CONFIG_NAME}.id must be a valid application id matching the selected application: ${String(appId)}.`);
311
313
  }
312
314
  const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
313
315
  parseSemver(value.version);
@@ -406,8 +408,8 @@ async function loadContext(requestedWorkspaceRoot,appId){
406
408
  const rootConfig=validateRootConfig(await readJson(rootConfigPath,ROOT_CONFIG_NAME),rootConfigPath);
407
409
  if(!is.string(appId)||!APP_ID_PATTERN.test(appId))fail(`Unsafe app id: ${String(appId)}`);
408
410
  const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
409
- const appRoot=resolveInside(appsRoot,appId,'app id');
410
- await assertContainedRealPath(appsRoot,appRoot,`apps/${appId}`);
411
+ const appRoot=resolveAppRoot(workspaceRoot,rootConfig,appId);
412
+ await assertContainedRealPath(appsRoot,appRoot,appId);
411
413
  const configPath=path.join(appRoot,APP_CONFIG_NAME);
412
414
  const config=validateAppConfig(await readJson(configPath,`${appId}/${APP_CONFIG_NAME}`),appId,rootConfig,configPath);
413
415
  return {
@@ -427,7 +429,8 @@ function destinationJoin(root,relative){
427
429
  }
428
430
 
429
431
  function appPackagePath(context, relative) {
430
- return `apps/${context.appId}/${relative}`;
432
+ const root=appRelativeRoot(context.rootConfig,context.appId);
433
+ return root?`${root}/${relative}`:relative;
431
434
  }
432
435
 
433
436
  function packageResourceUrl(relative) {
@@ -502,9 +505,13 @@ async function collectPackageRecords(context,{signal}={}){
502
505
  records,
503
506
  destinations,
504
507
  signal,
505
- label:`apps/${context.appId}`
508
+ label:context.appId
506
509
  });
507
510
  }
511
+ // App ownership comes from its selected files, including in a root layout.
512
+ for(const record of records){
513
+ record.appRelativePath=path.relative(context.appRoot,record.source).split(path.sep).join('/');
514
+ }
508
515
  for(const sharedId of context.config.shared){
509
516
  for(const route of context.rootConfig.sharedPayloads[sharedId]){
510
517
  const sourceRoot=resolveInside(context.workspaceRoot,route.source,`sharedPayloads.${sharedId}.source`);
@@ -532,19 +539,19 @@ async function collectPackageRecords(context,{signal}={}){
532
539
  return records;
533
540
  }
534
541
 
535
- async function browserDocuments(records,entry,appPrefix){
542
+ async function browserDocuments(records,entry){
536
543
  let entryDocument=null;
537
544
  const documents=[];
538
545
  for(const record of records){
546
+ if(record.appRelativePath===undefined)continue;
539
547
  const extension=path.posix.extname(record.destination).toLocaleLowerCase('en-US');
540
548
  if(extension!=='.html'&&extension!=='.htm')continue;
541
- const documentPath=record.destination.startsWith(appPrefix)
542
- ?record.destination.slice(appPrefix.length):record.destination;
549
+ const documentPath=record.appRelativePath;
543
550
  const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
544
551
  documentPath
545
552
  });
546
553
  const document={path:documentPath,packagePath:record.destination,...copyJson(inspected)};
547
- if(record.destination===`${appPrefix}${entry}`){
554
+ if(documentPath===entry){
548
555
  entryDocument=document;
549
556
  }else if(inspected.bases.length>0){
550
557
  documents.push(document);
@@ -567,6 +574,16 @@ async function optionalDescriptor(context){
567
574
 
568
575
  async function inspectContext(context,{signal}={}){
569
576
  const records=await collectPackageRecords(context,{signal});
577
+ const documents=await browserDocuments(records,context.config.entry);
578
+ const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
579
+ context.appId,context.config.entry,documents.map(document=>document.path)
580
+ ):[];
581
+ for(const redirect of navigation){
582
+ const selected=records.find(record=>pathKey(record.destination)===pathKey(redirect.path));
583
+ if(selected&&!(await readFile(selected.source,'utf8')).includes('<!-- Arcane root application navigation -->')){
584
+ fail(`Root application navigation would replace selected content: ${redirect.path}.`);
585
+ }
586
+ }
570
587
  return {
571
588
  appId:context.appId,
572
589
  displayName:context.config.displayName,
@@ -583,8 +600,8 @@ async function inspectContext(context,{signal}={}){
583
600
  }),
584
601
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
585
602
  descriptor:await optionalDescriptor(context),
586
- browserDocuments:await browserDocuments(records,context.config.entry,appPackagePath(context,'')),
587
- files:[...new Set(['index.html',...records.map(record=>record.destination)])].sort(compareText),
603
+ browserDocuments:documents,
604
+ files:[...new Set(['index.html',...records.map(record=>record.destination),...navigation.map(redirect=>redirect.path)])].sort(compareText),
588
605
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
589
606
  };
590
607
  }
@@ -595,6 +612,12 @@ export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}={}){
595
612
  await readJson(path.join(workspaceRoot,ROOT_CONFIG_NAME),ROOT_CONFIG_NAME),
596
613
  path.join(workspaceRoot,ROOT_CONFIG_NAME)
597
614
  );
615
+ if(rootConfig.appsRoot==='.'){
616
+ const configPath=path.join(workspaceRoot,APP_CONFIG_NAME);
617
+ const value=await readJson(configPath,APP_CONFIG_NAME);
618
+ const config=validateAppConfig(value,value?.id,rootConfig,configPath);
619
+ return [config.id];
620
+ }
598
621
  const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
599
622
  const entries=await readdir(appsRoot,{withFileTypes:true});
600
623
  const apps=[];
@@ -734,9 +757,12 @@ async function replaceDirectory(stagingRoot,outputRoot){
734
757
  async function packageWithContext(context,options={}){
735
758
  const {signal,onEvent,browserPwa=true}=options;
736
759
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
737
- const appPath=`apps/${context.appId}`;
760
+ const appPath=appRelativeRoot(context.rootConfig,context.appId);
738
761
  const entryPath=appPackagePath(context,context.config.entry);
739
762
  const inspected=await inspectContext(context,{signal});
763
+ const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
764
+ context.appId,context.config.entry,inspected.browserDocuments.map(document=>document.path)
765
+ ):[];
740
766
  if(options.dryRun){
741
767
  return {
742
768
  appId:context.appId,
@@ -787,6 +813,18 @@ async function packageWithContext(context,options={}){
787
813
  }else{
788
814
  await copyBase();
789
815
  }
816
+ for(const redirect of navigation){
817
+ throwIfAborted(signal);
818
+ const filePath=path.join(stagingRoot,...redirect.path.split('/'));
819
+ try{
820
+ const current=await readFile(filePath,'utf8');
821
+ if(!current.includes('<!-- Arcane root application navigation -->')){
822
+ fail(`Root application navigation would replace package content: ${redirect.path}.`);
823
+ }
824
+ }catch(error){if(error.code!=='ENOENT')throw error;}
825
+ await mkdir(path.dirname(filePath),{recursive:true});
826
+ await writeFile(filePath,redirect.content,'utf8');
827
+ }
790
828
  const files=await listOutputFiles(stagingRoot,{signal});
791
829
  // Traverse actual browser resources after the adapter finishes. Files
792
830
  // included only as application documents retain their original content.
@@ -807,7 +845,7 @@ async function packageWithContext(context,options={}){
807
845
  const appDocument=context.config.include.some(function includesAppDocument(selected){
808
846
  return sameOrDescendant(document.path,selected);
809
847
  });
810
- if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
848
+ if(appDocument&&(appPath===''||document.managedMaps.length>0)&&inventory.has(document.packagePath)){
811
849
  documentPaths.add(document.packagePath);
812
850
  }
813
851
  }
@@ -833,8 +871,9 @@ async function packageWithContext(context,options={}){
833
871
  const documentUrl=entryDocument?.bases[0]?.href
834
872
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
835
873
  const pending=[{file:entryPath,documentUrl}];
874
+ const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
836
875
  for(const file of files){
837
- if(/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file)
876
+ if((sharedFiles.has(file)||/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file))
838
877
  &&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
839
878
  if(pwaEnabled&&path.posix.basename(file)==='arcane.importmap.json'){
840
879
  pending.push({file,documentUrl});
@@ -906,6 +945,15 @@ async function packageWithContext(context,options={}){
906
945
  if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
907
946
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
908
947
  }
948
+ const installed=pwaEnabled?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
949
+ const navigationAliases=navigation.length?{
950
+ './':packageResourceUrl(entryPath),
951
+ [`./apps/${context.appId}`]:packageResourceUrl(entryPath),
952
+ [`./apps/${context.appId}/`]:packageResourceUrl(entryPath),
953
+ ...Object.fromEntries(navigation.map(redirect=>[
954
+ packageResourceUrl(redirect.path),packageResourceUrl(redirect.target.slice(1))
955
+ ]))
956
+ }:undefined;
909
957
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
910
958
  app:{
911
959
  id:context.appId,
@@ -914,6 +962,8 @@ async function packageWithContext(context,options={}){
914
962
  entry:packageResourceUrl(entryPath)
915
963
  },
916
964
  appPath,
965
+ ...(installed?.direct?{runtimeBase:`.${installed.browserRuntimeBase}`} : {}),
966
+ ...(navigationAliases?{navigationAliases}:{}),
917
967
  sdkVersion:assetVersion,
918
968
  pwa:context.config.pwa,
919
969
  files,
@@ -394,11 +394,25 @@ function installPwaWorker(manifest, clientUrl) {
394
394
  );
395
395
  }
396
396
 
397
+ function navigationRedirect(url) {
398
+ const exact = navigationAliases.get(url);
399
+ if (exact) return {location: exact, resource: cacheUrl(exact)};
400
+ const source = new URL(url);
401
+ const query = source.search;
402
+ if (!query) return null;
403
+ source.search = '';
404
+ const selected = navigationAliases.get(source.href);
405
+ if (!selected) return null;
406
+ const destination = new URL(selected);
407
+ destination.search = destination.search ? `${destination.search}&${query.slice(1)}` : query;
408
+ return {location: destination.href, resource: cacheUrl(selected)};
409
+ }
410
+
397
411
  async function requestedResource(request, url) {
398
412
  await restored;
399
- const redirect = request.mode === 'navigate' ? navigationAliases.get(url) : null;
400
- if (redirect && cacheUrl(redirect) !== url && ownedUrls.has(cacheUrl(redirect))) {
401
- return {response: Response.redirect(redirect, 302), done: Promise.resolve(null)};
413
+ const redirect = request.mode === 'navigate' ? navigationRedirect(url) : null;
414
+ if (redirect && cacheUrl(redirect.location) !== url && ownedUrls.has(redirect.resource)) {
415
+ return {response: Response.redirect(redirect.location, 302), done: Promise.resolve(null)};
402
416
  }
403
417
  if (!ownedUrls.has(url)) {
404
418
  return {response: await fetch(request), done: Promise.resolve(null)};
@@ -422,7 +436,7 @@ function installPwaWorker(manifest, clientUrl) {
422
436
  return;
423
437
  }
424
438
  if (manifestRestored && !ownedUrls.has(url)
425
- && !(request.mode === 'navigate' && navigationAliases.has(url))) {
439
+ && !(request.mode === 'navigate' && navigationRedirect(url))) {
426
440
  return;
427
441
  }
428
442
  const resource = requestedResource(request, url);
package/src/pwa.mjs CHANGED
@@ -202,6 +202,7 @@ export function createPwaArtifacts(
202
202
  mode = 'release',
203
203
  runtimeBase = './arcane/sdk/',
204
204
  appBase,
205
+ installationId,
205
206
  appPath = '',
206
207
  navigationAliases,
207
208
  revision
@@ -218,7 +219,7 @@ export function createPwaArtifacts(
218
219
  // Relocating app files must not change an existing installed app's default identity.
219
220
  const installationBase = appBase ?? (mode === 'development' ? applicationBase : './');
220
221
  const manifest = {
221
- id: installationBase,
222
+ id: installationId ?? installationBase,
222
223
  name: app.displayName,
223
224
  short_name: app.displayName,
224
225
  start_url: manifestUrl(app.entry, appPath ? basePath : applicationBase),