arcane-os 0.26.0 → 0.27.1

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,15 @@ 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 ? {
1085
+ installationId:`/apps/${routeSet.appId}/`,
1086
+ legacyAppPath:`apps/${routeSet.appId}`
1087
+ } : {}),
1088
+ runtimeBase: selectedRoutes.browserRuntimeBase
1089
+ ?? selectedRoutes.installed?.browserRuntimeBase ?? '/arcane/sdk/',
1050
1090
  mode: 'development'
1051
1091
  }
1052
1092
  );
@@ -1072,8 +1112,8 @@ async function startOwnedDevServer({
1072
1112
  pwaStates.set(selectedRoutes, state);
1073
1113
  if (selectedRoutes === currentSourceRoutes) currentPwaState = state;
1074
1114
  }
1075
- if (targetPath === '/arcane-sw.js'
1076
- || targetPath === '/arcane-offline.json') {
1115
+ if (targetPath.endsWith('/arcane-sw.js')
1116
+ || targetPath.endsWith('/arcane-offline.json')) {
1077
1117
  if (!state.inventoryTask) {
1078
1118
  // Timestamp selection before traversal, so an older snapshot
1079
1119
  // finishing later cannot acquire a newer HTTP modification date.
@@ -1136,12 +1176,30 @@ async function startOwnedDevServer({
1136
1176
  const target=parseRequestTarget(request.url);
1137
1177
  if(!target){deny(response,400,'Invalid request path.');return;}
1138
1178
  const {segments}=target;
1139
- const generatedPwaPath = ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs']
1140
- .includes(target.path);
1141
- const appRequest = segments[0] === 'apps' && segments[1] === routeSet.appId;
1179
+ const legacyAppRequest = mode === 'source' && routeSet.config.appsRoot === '.'
1180
+ && segments[0] === 'apps' && segments[1] === routeSet.appId;
1181
+ const legacyPwaRequest = legacyAppRequest && segments.length === 3
1182
+ && ['arcane-sw.js', 'arcane-offline.json'].includes(segments[2]);
1183
+ const generatedPwaPath = legacyPwaRequest
1184
+ || ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs']
1185
+ .includes(target.path);
1186
+ const requestedMapping = currentSourceRoutes.mappings.find(function currentRequestMapping(route) {
1187
+ return route.prefix.every(function currentRequestSegment(segment,index) {
1188
+ return segments[index] === segment;
1189
+ });
1190
+ });
1191
+ const appRequest = requestedMapping?.kind === 'app' || legacyAppRequest;
1142
1192
  const selectedRoutes = mode === 'source' && (appRequest || segments.length === 0 || generatedPwaPath)
1143
1193
  ? await refreshSourceRoutes() : currentSourceRoutes;
1144
1194
  const pwaEnabled = mode === 'source' ? selectedRoutes.app?.pwa?.enabled === true : routeSet.pwa;
1195
+ if (legacyAppRequest && !(pwaEnabled && legacyPwaRequest)) {
1196
+ const legacyPath = target.pathname.slice(`/apps/${routeSet.appId}`.length);
1197
+ const location = !legacyPath || legacyPath === '/' || legacyPath === '/index.html'
1198
+ ? selectedRoutes.startPath : legacyPath;
1199
+ response.writeHead(302,{location:`${location}${target.search}`});
1200
+ response.end();
1201
+ return;
1202
+ }
1145
1203
  if (mode === 'source' && pwaEnabled
1146
1204
  && generatedPwaPath) {
1147
1205
  const generated = await sourcePwaArtifact(target.path, selectedRoutes);
@@ -1163,7 +1221,7 @@ async function startOwnedDevServer({
1163
1221
  return;
1164
1222
  }
1165
1223
  if(segments.length===0){
1166
- response.writeHead(302,{location:selectedRoutes.startPath});
1224
+ response.writeHead(302,{location:`${selectedRoutes.startPath}${target.search}`});
1167
1225
  response.end();
1168
1226
  return;
1169
1227
  }
@@ -1195,7 +1253,7 @@ async function startOwnedDevServer({
1195
1253
  const extension=path.extname(opened.candidate).toLowerCase();
1196
1254
  const html=extension==='.html'||extension==='.htm';
1197
1255
  const selectedPwaDocument = mode === 'source' && pwaEnabled && html
1198
- && mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId
1256
+ && mapping.kind === 'app'
1199
1257
  && selectedRoutes.app.include.includes(relative.join('/'));
1200
1258
  const selectedDocument = target.path === selectedRoutes.startPath || selectedPwaDocument;
1201
1259
  const managedDocument = !selectedDocument && html && await isManagedDocument(opened);
@@ -1204,7 +1262,7 @@ async function startOwnedDevServer({
1204
1262
  // A live server can span an SDK upgrade. Refresh the small
1205
1263
  // version record on navigation, not on each resource request.
1206
1264
  if(entryDocument||managedMap)rememberAssetVersion(await selectedAssetVersion());
1207
- const runtimeResource=segments[0]==='arcane';
1265
+ const runtimeResource=mapping.kind!=='app';
1208
1266
  const browserResource=['script','style','worker','sharedworker','serviceworker']
1209
1267
  .includes(request.headers['sec-fetch-dest']);
1210
1268
  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,13 @@ 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 legacyAppPath=context.rootConfig.appsRoot==='.'?`apps/${context.appId}`:undefined;
761
+ const appPath=appRelativeRoot(context.rootConfig,context.appId);
738
762
  const entryPath=appPackagePath(context,context.config.entry);
739
763
  const inspected=await inspectContext(context,{signal});
764
+ const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
765
+ context.appId,context.config.entry,inspected.browserDocuments.map(document=>document.path)
766
+ ):[];
740
767
  if(options.dryRun){
741
768
  return {
742
769
  appId:context.appId,
@@ -749,7 +776,11 @@ async function packageWithContext(context,options={}){
749
776
  PWA_MANIFEST_NAME,
750
777
  PWA_OFFLINE_MANIFEST_NAME,
751
778
  PWA_WORKER_NAME,
752
- PWA_BOOTSTRAP_NAME
779
+ PWA_BOOTSTRAP_NAME,
780
+ ...(legacyAppPath?[
781
+ `${legacyAppPath}/${PWA_WORKER_NAME}`,
782
+ `${legacyAppPath}/${PWA_OFFLINE_MANIFEST_NAME}`
783
+ ]:[])
753
784
  ]:[])
754
785
  ].sort(compareText)
755
786
  };
@@ -787,6 +818,18 @@ async function packageWithContext(context,options={}){
787
818
  }else{
788
819
  await copyBase();
789
820
  }
821
+ for(const redirect of navigation){
822
+ throwIfAborted(signal);
823
+ const filePath=path.join(stagingRoot,...redirect.path.split('/'));
824
+ try{
825
+ const current=await readFile(filePath,'utf8');
826
+ if(!current.includes('<!-- Arcane root application navigation -->')){
827
+ fail(`Root application navigation would replace package content: ${redirect.path}.`);
828
+ }
829
+ }catch(error){if(error.code!=='ENOENT')throw error;}
830
+ await mkdir(path.dirname(filePath),{recursive:true});
831
+ await writeFile(filePath,redirect.content,'utf8');
832
+ }
790
833
  const files=await listOutputFiles(stagingRoot,{signal});
791
834
  // Traverse actual browser resources after the adapter finishes. Files
792
835
  // included only as application documents retain their original content.
@@ -807,7 +850,7 @@ async function packageWithContext(context,options={}){
807
850
  const appDocument=context.config.include.some(function includesAppDocument(selected){
808
851
  return sameOrDescendant(document.path,selected);
809
852
  });
810
- if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
853
+ if(appDocument&&(appPath===''||document.managedMaps.length>0)&&inventory.has(document.packagePath)){
811
854
  documentPaths.add(document.packagePath);
812
855
  }
813
856
  }
@@ -833,8 +876,9 @@ async function packageWithContext(context,options={}){
833
876
  const documentUrl=entryDocument?.bases[0]?.href
834
877
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
835
878
  const pending=[{file:entryPath,documentUrl}];
879
+ const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
836
880
  for(const file of files){
837
- if(/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file)
881
+ if((sharedFiles.has(file)||/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file))
838
882
  &&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
839
883
  if(pwaEnabled&&path.posix.basename(file)==='arcane.importmap.json'){
840
884
  pending.push({file,documentUrl});
@@ -906,6 +950,15 @@ async function packageWithContext(context,options={}){
906
950
  if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
907
951
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
908
952
  }
953
+ const installed=pwaEnabled?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
954
+ const navigationAliases=navigation.length?{
955
+ './':packageResourceUrl(entryPath),
956
+ [`./apps/${context.appId}`]:packageResourceUrl(entryPath),
957
+ [`./apps/${context.appId}/`]:packageResourceUrl(entryPath),
958
+ ...Object.fromEntries(navigation.map(redirect=>[
959
+ packageResourceUrl(redirect.path),packageResourceUrl(redirect.target.slice(1))
960
+ ]))
961
+ }:undefined;
909
962
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
910
963
  app:{
911
964
  id:context.appId,
@@ -914,6 +967,9 @@ async function packageWithContext(context,options={}){
914
967
  entry:packageResourceUrl(entryPath)
915
968
  },
916
969
  appPath,
970
+ ...(legacyAppPath?{legacyAppPath}:{}),
971
+ ...(installed?.direct?{runtimeBase:`.${installed.browserRuntimeBase}`} : {}),
972
+ ...(navigationAliases?{navigationAliases}:{}),
917
973
  sdkVersion:assetVersion,
918
974
  pwa:context.config.pwa,
919
975
  files,
@@ -924,7 +980,9 @@ async function packageWithContext(context,options={}){
924
980
  if(inventory.has(artifact.path)){
925
981
  fail(`Package content overlaps generated PWA file: ${artifact.path}.`);
926
982
  }
927
- await writeFile(path.join(stagingRoot,artifact.path),artifact.content,'utf8');
983
+ const artifactPath=path.join(stagingRoot,...artifact.path.split('/'));
984
+ await mkdir(path.dirname(artifactPath),{recursive:true});
985
+ await writeFile(artifactPath,artifact.content,'utf8');
928
986
  files.push(artifact.path);
929
987
  }
930
988
  for(const [documentPath,document] of pwaDocumentSources){
@@ -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);