arcane-os 0.51.2 → 0.52.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.
@@ -324,7 +324,8 @@ async function serveGeneratedRepresentation(fileServer, request, response, {
324
324
  }
325
325
 
326
326
  async function serveSourceFile(fileServer, request, response, opened, {
327
- assetVersion, revalidate = false, onReference, pwaEntry = false, lastModified
327
+ assetVersion, revalidate = false, onReference, pwaEntry = false, lastModified,
328
+ representationTracked = false
328
329
  } = {}) {
329
330
  const extension = path.extname(opened.candidate).toLowerCase();
330
331
  const rewrite = assetVersion !== undefined && /\.(?:m?js|html?|css|json)$/iu.test(extension);
@@ -336,9 +337,27 @@ async function serveSourceFile(fileServer, request, response, opened, {
336
337
  response.writeHead = function writeDevelopmentHeaders(...arguments_) {
337
338
  this.removeHeader('ETag');
338
339
  this.removeHeader('X-Content-Type-Options');
340
+ if (representationTracked) {
341
+ if (lastModified) this.setHeader('Last-Modified', lastModified.toUTCString());
342
+ else this.removeHeader('Last-Modified');
343
+ }
339
344
  return writeHead.apply(this, arguments_);
340
345
  };
341
- await fileServer.serveFile(opened.candidate, request, response);
346
+ const modifiedSince = request.headers['if-modified-since'];
347
+ const range = request.headers.range;
348
+ if (representationTracked) {
349
+ const currentTime = lastModified ? Date.parse(lastModified.toUTCString()) : NaN;
350
+ if (!(Date.parse(modifiedSince) >= currentTime)) delete request.headers['if-modified-since'];
351
+ if (request.headers['if-range'] && !(Date.parse(request.headers['if-range']) >= currentTime)) {
352
+ delete request.headers.range;
353
+ }
354
+ }
355
+ try {
356
+ await fileServer.serveFile(opened.candidate, request, response);
357
+ } finally {
358
+ if (modifiedSince !== undefined) request.headers['if-modified-since'] = modifiedSince;
359
+ if (range !== undefined) request.headers.range = range;
360
+ }
342
361
  return;
343
362
  }
344
363
  await serveGeneratedRepresentation(
@@ -514,17 +533,23 @@ function applicationSourcePath(config,appId,relative='') {
514
533
  return `/${root?`${root}/`:''}${relative}`;
515
534
  }
516
535
 
536
+ function selectedApplicationDocument(app,relative){
537
+ return app.documents===undefined||relative===app.entry||app.documents.includes(relative);
538
+ }
539
+
517
540
  async function packagedRoutes(releaseRoot){
518
541
  if(!is.string(releaseRoot)||!releaseRoot.trim())fail('releaseRoot is required in packaged mode.','ARCANE_USAGE');
519
542
  const requested=path.resolve(releaseRoot);
520
543
  const canonical=await canonicalRealDirectory(requested,'Packaged release root');
521
544
  let pwa = false;
522
545
  let startPath = '/index.html';
546
+ let rootDocument = null;
523
547
  try {
524
548
  const release = JSON.parse(
525
549
  await readFile(path.join(canonical, 'ARCANE_APP_RELEASE.json'), 'utf8')
526
550
  );
527
551
  const start = release.app.start ?? release.app.entry;
552
+ if (release.app.rootDocument === './index.html') rootDocument = '/index.html';
528
553
  const startUrl = new URL(start, 'http://arcane.invalid/');
529
554
  startPath = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
530
555
  } catch (error) {
@@ -541,6 +566,7 @@ async function packagedRoutes(releaseRoot){
541
566
  appId:null,
542
567
  pwa,
543
568
  startPath,
569
+ rootDocument,
544
570
  mappings:[{
545
571
  prefix:[],
546
572
  root:canonical
@@ -548,7 +574,7 @@ async function packagedRoutes(releaseRoot){
548
574
  };
549
575
  }
550
576
 
551
- async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourcePaths) {
577
+ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourcePaths, runtimeDocuments = new Set()) {
552
578
  const records = new Map();
553
579
  const resources = new Map();
554
580
  for (const mapping of mappings) {
@@ -590,7 +616,7 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
590
616
  const pending = [{url: entryUrl, documentUrl: entryUrl}];
591
617
  const visited = new Set();
592
618
  const referencesByPath = new Map();
593
- for (const selected of routeSet.app.include) {
619
+ for (const selected of routeSet.app.documents ?? routeSet.app.include) {
594
620
  if (!/\.html?$/iu.test(selected)) continue;
595
621
  const url = new URL(
596
622
  applicationSourcePath(routeSet.config,routeSet.appId,selected.split('/').map(encodeURIComponent).join('/')),
@@ -609,6 +635,9 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
609
635
  if (!/\.(?:m?js|html?|css)$/iu.test(pathname) && !managedMap) continue;
610
636
  visited.add(context);
611
637
  resourcePaths.add(decodeURIComponent(pathname));
638
+ if (record.mapping.kind !== 'app' && /\.html?$/iu.test(pathname)) {
639
+ runtimeDocuments.add(decodeURIComponent(pathname));
640
+ }
612
641
  let references = referencesByPath.get(pathname);
613
642
  if (!references) {
614
643
  const opened = await openSafeFile(record.mapping.root, record.relative);
@@ -654,6 +683,9 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
654
683
  }
655
684
  if (target.origin !== origin || !resources.has(target.pathname)) continue;
656
685
  resourceUrls.add(`${target.pathname}${target.search}`);
686
+ const targetRecord = resources.get(target.pathname);
687
+ if (kind === 'document' && targetRecord.mapping.kind === 'app'
688
+ && !selectedApplicationDocument(routeSet.app, targetRecord.relative.join('/'))) continue;
657
689
  const traversable = kind !== 'fetch'
658
690
  && (kind !== 'asset' || /\.css(?:[?#]|$)/iu.test(url));
659
691
  if (traversable) {
@@ -942,21 +974,33 @@ async function startOwnedDevServer({
942
974
  );
943
975
  return managed;
944
976
  }
945
- function sourceRepresentationModifiedAt(opened, version, pwaEntry) {
977
+ function sourceRepresentation(opened, version, pwaEntry, rewritten) {
946
978
  const modifiedAt = opened.modifiedAt.getTime();
947
979
  const previous = representationMetadata.get(opened.candidate);
948
980
  if (previous?.modifiedAt === modifiedAt
949
- && previous.version === version && previous.pwaEntry === pwaEntry) {
950
- return previous.lastModified;
981
+ && previous.version === version && previous.pwaEntry === pwaEntry
982
+ && previous.rewritten === rewritten) {
983
+ if (!previous.lastModified && Date.now() >= previous.ambiguousThrough + 1000) {
984
+ previous.lastModified = new Date();
985
+ }
986
+ return previous;
951
987
  }
952
988
  const lastModified = new Date(
953
989
  Math.max(modifiedAt, generatorModifiedAt, assetVersionModifiedAt, previous ? Date.now() : 0)
954
990
  );
955
- representationMetadata.set(
956
- opened.candidate,
957
- {modifiedAt, version, pwaEntry, lastModified}
958
- );
959
- return lastModified;
991
+ const previousTime = previous?.lastModified
992
+ ? Date.parse(previous.lastModified.toUTCString()) : previous?.ambiguousThrough;
993
+ // HTTP dates cannot distinguish two representations within one second.
994
+ // Omit that ambiguous validator until a later request can date it honestly.
995
+ const ambiguous = previousTime !== undefined
996
+ && Date.parse(lastModified.toUTCString()) <= previousTime;
997
+ const record = {
998
+ modifiedAt, version, pwaEntry, rewritten,
999
+ lastModified: ambiguous ? undefined : lastModified,
1000
+ ambiguousThrough: ambiguous ? previousTime : undefined
1001
+ };
1002
+ representationMetadata.set(opened.candidate, record);
1003
+ return record;
960
1004
  }
961
1005
  for(const mapping of mappings){
962
1006
  const info=await lstat(mapping.root);
@@ -985,19 +1029,30 @@ async function startOwnedDevServer({
985
1029
  }
986
1030
  async function readCurrentSourceRoutes() {
987
1031
  throwIfAborted(signal);
1032
+ function optionalSourceMetadata(error) {
1033
+ if (error.code !== 'ENOENT') throw error;
1034
+ return null;
1035
+ }
988
1036
  let manifestPath = path.join(appMapping.root, APP_DESCRIPTOR_NAME);
1037
+ const [rootIndex, descriptorInput] = await Promise.all(
1038
+ [
1039
+ lstat(path.join(routeSet.workspaceRoot, 'index.html')).catch(optionalSourceMetadata),
1040
+ lstat(manifestPath).catch(optionalSourceMetadata)
1041
+ ]
1042
+ );
1043
+ const rootDocument = rootIndex?.isFile() ? '/index.html' : null;
989
1044
  let authored = true;
990
- let input;
991
- try {
992
- input = await lstat(manifestPath);
993
- } catch (error) {
994
- if (error.code !== 'ENOENT') throw error;
1045
+ let input = descriptorInput;
1046
+ if (!input) {
995
1047
  authored = false;
996
1048
  manifestPath = path.join(appMapping.root, APP_CONFIG_NAME);
997
1049
  input = await lstat(manifestPath);
998
1050
  }
999
1051
  if (sourceManifestInput?.path === manifestPath
1000
1052
  && sourceManifestInput.modifiedAt === input.mtimeMs) {
1053
+ if (currentSourceRoutes.rootDocument !== rootDocument) {
1054
+ currentSourceRoutes = {...currentSourceRoutes, rootDocument};
1055
+ }
1001
1056
  return currentSourceRoutes;
1002
1057
  }
1003
1058
  const value = JSON.parse(await readFile(manifestPath, 'utf8'));
@@ -1019,6 +1074,7 @@ async function startOwnedDevServer({
1019
1074
  currentSourceRoutes = {
1020
1075
  ...routeSet,
1021
1076
  app: manifest,
1077
+ rootDocument,
1022
1078
  startPath: applicationSourcePath(routeSet.config,routeSet.appId,manifest.entry),
1023
1079
  mappings: mappings.map(
1024
1080
  function currentSourceMapping(mapping) {
@@ -1035,7 +1091,7 @@ async function startOwnedDevServer({
1035
1091
  function developmentPwaArtifacts(selectedRoutes, assets = [], version = assetVersion) {
1036
1092
  const rootApp = selectedRoutes.config.appsRoot === '.';
1037
1093
  const appBase = applicationSourcePath(selectedRoutes.config,selectedRoutes.appId);
1038
- const navigationAliases = {'/': selectedRoutes.startPath};
1094
+ const navigationAliases = selectedRoutes.rootDocument ? {} : {'/': selectedRoutes.startPath};
1039
1095
  return createPwaArtifacts(
1040
1096
  {
1041
1097
  app: {
@@ -1063,12 +1119,21 @@ async function startOwnedDevServer({
1063
1119
  function rememberPwaArtifacts(state, artifacts, generatedAt = new Date()) {
1064
1120
  for (const file of artifacts.files) {
1065
1121
  const previous = state.metadata.get(file.path);
1066
- const lastModified = previous?.content === file.content
1122
+ const previousTime = previous?.lastModified
1123
+ ? Date.parse(previous.lastModified.toUTCString()) : previous?.ambiguousThrough;
1124
+ let lastModified = previous?.content === file.content
1067
1125
  ? previous.lastModified
1068
1126
  : generatedAt;
1127
+ if (!lastModified && generatedAt.getTime() >= previousTime + 1000) {
1128
+ lastModified = generatedAt;
1129
+ }
1130
+ // A changed alias must not validate the prior body within one HTTP second.
1131
+ const ambiguous = previous?.content !== file.content && previousTime !== undefined
1132
+ && Date.parse(generatedAt.toUTCString()) <= previousTime;
1133
+ if (ambiguous) lastModified = undefined;
1069
1134
  state.metadata.set(
1070
1135
  file.path,
1071
- {content: file.content, lastModified}
1136
+ {content: file.content, lastModified, ambiguousThrough: lastModified ? undefined : previousTime}
1072
1137
  );
1073
1138
  }
1074
1139
  state.artifacts = artifacts;
@@ -1089,7 +1154,7 @@ async function startOwnedDevServer({
1089
1154
  const generatedAt = new Date();
1090
1155
  state.inventoryTask = Promise.all(
1091
1156
  [
1092
- sourcePwaAssets(selectedRoutes, selectedRoutes.mappings, signal, pwaResourceUrls, resourcePaths),
1157
+ sourcePwaAssets(selectedRoutes, selectedRoutes.mappings, signal, pwaResourceUrls, resourcePaths, runtimeDocuments),
1093
1158
  selectedAssetVersion().then(
1094
1159
  function rememberCurrentInventoryVersion(version) {
1095
1160
  if (selectedRoutes === currentSourceRoutes) rememberAssetVersion(version);
@@ -1124,6 +1189,66 @@ async function startOwnedDevServer({
1124
1189
  // Remember actual resource edges as their owners are served, not every
1125
1190
  // HTML/JS/CSS file in an application's document or attachment corpus.
1126
1191
  const resourcePaths=new Set([routeSet.startPath]);
1192
+ const runtimeDocuments=new Set();
1193
+ const componentReferences=new Map();
1194
+ async function isCurrentComponent(targetPath, selectedRoutes) {
1195
+ // Retain parsed edges, not lifetime classifications. Only current page
1196
+ // roots and observed runtime documents can make an original a component.
1197
+ const pending = [selectedRoutes.startPath, ...runtimeDocuments];
1198
+ for (const document of selectedRoutes.app.documents) {
1199
+ pending.push(applicationSourcePath(selectedRoutes.config, selectedRoutes.appId, document));
1200
+ }
1201
+ const visited = new Set();
1202
+ for (const ownerPath of pending) {
1203
+ throwIfAborted(signal);
1204
+ if (visited.has(ownerPath)) continue;
1205
+ visited.add(ownerPath);
1206
+ const target = parseRequestTarget(ownerPath.split('/').map(encodeURIComponent).join('/'));
1207
+ if (!target) continue;
1208
+ const mapping = selectedRoutes.mappings.find(function componentOwnerMapping(route) {
1209
+ return route.prefix.every(function componentOwnerSegment(segment, index) {
1210
+ return target.segments[index] === segment;
1211
+ });
1212
+ });
1213
+ if (!mapping) continue;
1214
+ const relative = target.segments.slice(mapping.prefix.length);
1215
+ if (!relative.length || (mapping.allow && !mapping.allow(relative))) continue;
1216
+ if (!/\.html?$/iu.test(ownerPath)) continue;
1217
+ const opened = await openSafeFile(mapping.root, relative, {readContent: false});
1218
+ if (!opened) continue;
1219
+ const modifiedAt = opened.modifiedAt.getTime();
1220
+ let record = componentReferences.get(opened.candidate);
1221
+ if (record?.modifiedAt !== modifiedAt) {
1222
+ const references = [];
1223
+ rewriteAssetReferences(await readFile(opened.candidate, 'utf8'), {
1224
+ filePath: opened.candidate,
1225
+ version: null,
1226
+ onReference: function currentComponentReference(reference) {
1227
+ if (reference.kind === 'component') references.push(reference);
1228
+ }
1229
+ });
1230
+ record = {modifiedAt, references};
1231
+ componentReferences.set(opened.candidate, record);
1232
+ }
1233
+ const ownerUrl = new URL(target.pathname, 'http://arcane.invalid');
1234
+ for (const reference of record.references) {
1235
+ let component;
1236
+ try {
1237
+ component = resolveAssetReference(reference, {ownerUrl});
1238
+ } catch {
1239
+ // Non-URL values retain their authored behavior.
1240
+ continue;
1241
+ }
1242
+ if (component.origin !== ownerUrl.origin) continue;
1243
+ const componentTarget = parseRequestTarget(component.pathname);
1244
+ if (!componentTarget) continue;
1245
+ const componentPath = componentTarget.path;
1246
+ if (componentPath === targetPath) return true;
1247
+ pending.push(componentPath);
1248
+ }
1249
+ }
1250
+ return false;
1251
+ }
1127
1252
  const requestTasks=new Set();
1128
1253
  const runFileWork=createFileWorkLimiter();
1129
1254
  async function serveDevelopmentRequest(request, response) {
@@ -1176,22 +1301,29 @@ async function startOwnedDevServer({
1176
1301
  );
1177
1302
  return;
1178
1303
  }
1179
- if(segments.length===0){
1304
+ const rootRequest = segments.length === 0;
1305
+ if (rootRequest && !selectedRoutes.rootDocument) {
1180
1306
  response.writeHead(302,{location:`${selectedRoutes.startPath}${target.search}`});
1181
1307
  response.end();
1182
1308
  return;
1183
1309
  }
1184
- const mapping = selectedRoutes.mappings.find(
1185
- function requestedSourceMapping(route) {
1186
- return route.prefix.every(
1187
- function matchingSourcePrefix(segment, index) {
1188
- return segments[index] === segment;
1189
- }
1190
- );
1191
- }
1192
- );
1310
+ const resourceSegments = rootRequest ? ['index.html'] : segments;
1311
+ const hostRoot = rootRequest && mode === 'source'
1312
+ && (selectedRoutes.config.appsRoot !== '.' || !sourcePathAllowed(resourceSegments, selectedRoutes.app));
1313
+ // Only the host's exact root document is outside app selection.
1314
+ // Its direct URL and every other resource keep the selected routes.
1315
+ const mapping = hostRoot ? {kind: 'host', root: selectedRoutes.workspaceRoot, prefix: []}
1316
+ : selectedRoutes.mappings.find(
1317
+ function requestedSourceMapping(route) {
1318
+ return route.prefix.every(
1319
+ function matchingSourcePrefix(segment, index) {
1320
+ return resourceSegments[index] === segment;
1321
+ }
1322
+ );
1323
+ }
1324
+ );
1193
1325
  if(!mapping){deny(response,404,'Not found.');return;}
1194
- const relative=segments.slice(mapping.prefix.length);
1326
+ const relative=resourceSegments.slice(mapping.prefix.length);
1195
1327
  if(relative.length===0){deny(response,404,'Not found.');return;}
1196
1328
  if(mapping.allow&&!mapping.allow(relative)){deny(response,404,'Not found.');return;}
1197
1329
  await runFileWork(async function serveMappedResource() {
@@ -1206,29 +1338,51 @@ async function startOwnedDevServer({
1206
1338
  deny(response,404,'Not found.');
1207
1339
  return;
1208
1340
  }
1341
+ if (hostRoot) {
1342
+ const representation = sourceRepresentation(opened, assetVersion, false, false);
1343
+ await serveSourceFile(
1344
+ fileServer,
1345
+ request,
1346
+ response,
1347
+ opened,
1348
+ {lastModified: representation.lastModified, representationTracked: true}
1349
+ );
1350
+ return;
1351
+ }
1352
+ const resourcePath = rootRequest ? selectedRoutes.rootDocument : target.path;
1209
1353
  const extension=path.extname(opened.candidate).toLowerCase();
1210
1354
  const html=extension==='.html'||extension==='.htm';
1355
+ const explicitDocuments = mode === 'source' && mapping.kind === 'app'
1356
+ && selectedRoutes.app.documents !== undefined;
1357
+ const explicitDocument = explicitDocuments && html
1358
+ && selectedApplicationDocument(selectedRoutes.app, relative.join('/'));
1211
1359
  const selectedPwaDocument = mode === 'source' && pwaEnabled && html
1212
1360
  && mapping.kind === 'app'
1213
- && selectedRoutes.app.include.includes(relative.join('/'));
1214
- const selectedDocument = target.path === selectedRoutes.startPath || selectedPwaDocument;
1215
- const managedDocument = !selectedDocument && html && await isManagedDocument(opened);
1361
+ && !explicitDocuments && selectedRoutes.app.include.includes(relative.join('/'));
1362
+ const selectedDocument = resourcePath === selectedRoutes.startPath || explicitDocument || selectedPwaDocument;
1363
+ const managedDocument = !selectedDocument && !explicitDocuments && html && await isManagedDocument(opened);
1216
1364
  const entryDocument = selectedDocument || managedDocument;
1217
1365
  const managedMap=path.basename(opened.candidate)==='arcane.importmap.json';
1218
1366
  // A live server can span an SDK upgrade. Refresh the small
1219
1367
  // version record on navigation, not on each resource request.
1220
1368
  if(entryDocument||managedMap)rememberAssetVersion(await selectedAssetVersion());
1221
1369
  const runtimeResource=mapping.kind!=='app';
1370
+ if (runtimeResource && html) runtimeDocuments.add(resourcePath);
1222
1371
  const browserResource=['script','style','worker','sharedworker','serviceworker']
1223
1372
  .includes(request.headers['sec-fetch-dest']);
1224
- const rewrite=runtimeResource||entryDocument||browserResource||managedMap
1225
- ||resourcePaths.has(target.path);
1373
+ // Selecting application pages does not turn retained originals
1374
+ // into resources. HTML components remain owned by html-import.
1375
+ const componentDocument=explicitDocuments&&html&&!selectedDocument
1376
+ &&await isCurrentComponent(resourcePath, selectedRoutes);
1377
+ const retainedDocument=explicitDocuments&&html&&!selectedDocument&&!componentDocument;
1378
+ const rewrite=!retainedDocument&&(runtimeResource||entryDocument||browserResource||managedMap
1379
+ ||componentDocument||resourcePaths.has(resourcePath));
1226
1380
  const onReference = function observeServedResource(reference) {
1227
1381
  const {url,kind,baseKind}=reference;
1228
1382
  if (pwaEnabled && kind !== 'fetch'
1229
1383
  && !(kind === 'import' && !/^(?:\.{1,2}\/|\/)/u.test(url))) {
1230
1384
  try {
1231
- const documentUrl = new URL(target.path, 'http://arcane.invalid');
1385
+ const documentUrl = new URL(resourcePath, 'http://arcane.invalid');
1232
1386
  const resource = resolveAssetReference(
1233
1387
  {...reference,url:versionAssetUrl(url,null)},
1234
1388
  {ownerUrl:documentUrl}
@@ -1243,11 +1397,17 @@ async function startOwnedDevServer({
1243
1397
  if(baseKind==='document'||kind==='fetch'||(kind==='asset'&&!/\.css(?:[?#]|$)/iu.test(url))
1244
1398
  ||(kind==='import'&&!/^(?:\.{1,2}\/|\/)/u.test(url)))return;
1245
1399
  try{
1246
- const documentUrl=new URL(target.path,'http://arcane.invalid');
1400
+ const documentUrl=new URL(resourcePath,'http://arcane.invalid');
1247
1401
  const resource=resolveAssetReference(reference,{ownerUrl:documentUrl});
1248
1402
  if(resource.origin===documentUrl.origin
1249
1403
  &&/\.(?:m?js|html?|css)$/iu.test(resource.pathname)){
1250
- resourcePaths.add(decodeURIComponent(resource.pathname));
1404
+ const resourcePath=decodeURIComponent(resource.pathname);
1405
+ const appPrefix=applicationSourcePath(selectedRoutes.config,selectedRoutes.appId);
1406
+ const appRelative=resourcePath.startsWith(appPrefix)?resourcePath.slice(appPrefix.length):null;
1407
+ if(kind==='document'&&mode==='source'&&appRelative!==null
1408
+ &&sourcePathAllowed(appRelative.split('/'),selectedRoutes.app)
1409
+ &&!selectedApplicationDocument(selectedRoutes.app,appRelative))return;
1410
+ resourcePaths.add(resourcePath);
1251
1411
  }
1252
1412
  }catch{ /* Non-URL values remain under their existing owner. */ }
1253
1413
  };
@@ -1255,6 +1415,8 @@ async function startOwnedDevServer({
1255
1415
  const pwaEntry = mode === 'source' && pwaEnabled && entryDocument;
1256
1416
  const transformed = pwaEntry || (selectedVersion !== undefined
1257
1417
  && /\.(?:m?js|html?|css|json)$/iu.test(extension));
1418
+ const representation = transformed || retainedDocument
1419
+ ? sourceRepresentation(opened, assetVersion, pwaEntry, transformed) : undefined;
1258
1420
  await serveSourceFile(
1259
1421
  fileServer,
1260
1422
  request,
@@ -1265,9 +1427,8 @@ async function startOwnedDevServer({
1265
1427
  revalidate: pwaEnabled || entryDocument || managedMap,
1266
1428
  pwaEntry,
1267
1429
  onReference,
1268
- lastModified: transformed
1269
- ? sourceRepresentationModifiedAt(opened, assetVersion, pwaEntry)
1270
- : undefined
1430
+ lastModified: representation?.lastModified,
1431
+ representationTracked: representation !== undefined
1271
1432
  }
1272
1433
  );
1273
1434
  });
@@ -166,6 +166,16 @@ function validatePathList(value,label,{required=false,allowRoot=false}={}){
166
166
  return normalized;
167
167
  }
168
168
 
169
+ export function normalizeAppDocuments(value,label='documents'){
170
+ if(value===undefined)return undefined;
171
+ if(!is.array(value))fail(`${label} must be an array of application-relative HTML paths.`);
172
+ return value.map(function normalizeDocumentPath(entry,index){
173
+ const document=normalizeRelativePath(entry,`${label}[${index}]`);
174
+ if(!/\.html?$/iu.test(document))fail(`${label}[${index}] must name an HTML or HTM file.`);
175
+ return document;
176
+ });
177
+ }
178
+
169
179
  function isAlwaysForbidden(relative){
170
180
  return relative.split('/').some(segment=>{
171
181
  const key=pathKey(segment);
@@ -308,7 +318,7 @@ function normalizeOptionalRecord(value,label){
308
318
  export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
309
319
  assertOnlyKeys(value,new Set([
310
320
  'schemaVersion','id','displayName','version','entry','strategy','security',
311
- 'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory'
321
+ 'localAIModelPolicy','include','exclude','shared','adapter','pwa','outputDirectory','documents'
312
322
  ]),`${appId}/${APP_CONFIG_NAME}`);
313
323
  if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
314
324
  if(!is.string(value.id)||value.id!==appId||!APP_ID_PATTERN.test(value.id)){
@@ -317,6 +327,7 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
317
327
  const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
318
328
  parseSemver(value.version);
319
329
  const entry=normalizeRelativePath(value.entry,`${appId}/${APP_CONFIG_NAME}.entry`);
330
+ const documents=normalizeAppDocuments(value.documents,`${appId}/${APP_CONFIG_NAME}.documents`);
320
331
  const outputDirectory=value.outputDirectory===undefined?undefined:normalizeRelativePath(
321
332
  value.outputDirectory,`${appId}/${APP_CONFIG_NAME}.outputDirectory`
322
333
  );
@@ -355,6 +366,7 @@ export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.j
355
366
  displayName,
356
367
  version:value.version,
357
368
  entry,
369
+ ...(documents===undefined?{}:{documents}),
358
370
  ...(outputDirectory===undefined?{}:{outputDirectory}),
359
371
  strategy:value.strategy,
360
372
  ...(value.pwa===undefined?{}:{pwa:normalizePwaConfig(value.pwa)}),
@@ -603,7 +615,23 @@ async function collectPackageRecords(context,{signal}={}){
603
615
  return records;
604
616
  }
605
617
 
606
- async function browserDocuments(records,entry){
618
+ async function browserDocuments(records,entry,selected){
619
+ if(selected!==undefined){
620
+ const appRecords=new Map(records.filter(function applicationRecord(record){
621
+ return record.appRelativePath!==undefined;
622
+ }).map(function applicationRecordPath(record){
623
+ return [record.appRelativePath,record];
624
+ }));
625
+ const documentPaths=[entry,...[...new Set(selected)].filter(function secondaryDocument(document){
626
+ return document!==entry;
627
+ }).sort(compareText)];
628
+ return Promise.all(documentPaths.map(async function inspectSelectedDocument(documentPath){
629
+ const record=appRecords.get(documentPath);
630
+ if(!record)fail(`Application document is missing from the selected files: ${documentPath}.`);
631
+ const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{documentPath});
632
+ return {path:documentPath,packagePath:record.destination,...copyJson(inspected)};
633
+ }));
634
+ }
607
635
  let entryDocument=null;
608
636
  const documents=[];
609
637
  for(const record of records){
@@ -638,12 +666,13 @@ async function optionalDescriptor(context){
638
666
 
639
667
  async function inspectContext(context,{signal,records}={}){
640
668
  const selectedRecords=records??await collectPackageRecords(context,{signal});
641
- const documents=await browserDocuments(selectedRecords,context.config.entry);
669
+ const documents=await browserDocuments(selectedRecords,context.config.entry,context.config.documents);
642
670
  return {
643
671
  appId:context.appId,
644
672
  displayName:context.config.displayName,
645
673
  version:context.config.version,
646
674
  entry:context.config.entry,
675
+ ...(context.config.documents===undefined?{}:{documents:[...context.config.documents]}),
647
676
  ...(context.config.outputDirectory===undefined?{}:{outputDirectory:context.config.outputDirectory}),
648
677
  strategy:context.config.strategy,
649
678
  ...(context.config.pwa===undefined?{}:{pwa:copyJson(context.config.pwa)}),
@@ -759,7 +788,7 @@ async function loadAdapter(context){
759
788
  return module;
760
789
  }
761
790
 
762
- function releaseManifest(context,files,pwaArtifacts){
791
+ function releaseManifest(context,files,pwaArtifacts,rootDocument){
763
792
  return {
764
793
  schemaVersion:1,
765
794
  kind:'arcane-app-release',
@@ -770,6 +799,7 @@ function releaseManifest(context,files,pwaArtifacts){
770
799
  version:context.config.version,
771
800
  entry:context.config.entry,
772
801
  start:packageResourceUrl(appPackagePath(context, context.config.entry)),
802
+ ...(rootDocument ? {rootDocument} : {}),
773
803
  strategy:context.config.strategy,
774
804
  shared:[...context.config.shared],
775
805
  ...(pwaArtifacts?{pwa:{
@@ -816,6 +846,11 @@ async function packageWithContext(context,options={}){
816
846
  const appPath=appRelativeRoot(context.rootConfig,context.appId);
817
847
  const entryPath=appPackagePath(context,context.config.entry);
818
848
  const records=await collectPackageRecords(context,{signal});
849
+ const rootDocument = records.some(
850
+ function selectedRootIndex(record) {
851
+ return record.destination === 'index.html';
852
+ }
853
+ ) ? './index.html' : undefined;
819
854
  const inspected=await inspectContext(context,{signal,records});
820
855
  if(options.dryRun){
821
856
  return {
@@ -859,9 +894,7 @@ async function packageWithContext(context,options={}){
859
894
  try{
860
895
  async function copyBase() {
861
896
  await copyRecords(records,stagingRoot,{signal,onEvent});
862
- if (!records.some(function selectedRootIndex(record) {
863
- return record.destination === 'index.html';
864
- })) {
897
+ if (!rootDocument) {
865
898
  await writePackageLauncher(context, stagingRoot);
866
899
  }
867
900
  }
@@ -888,9 +921,12 @@ async function packageWithContext(context,options={}){
888
921
  const offlineReferences=new Set();
889
922
  const entryUrl=new URL(packageResourceUrl(entryPath),'http://arcane.invalid/');
890
923
  const pwaDocumentSources=new Map();
924
+ const explicitDocumentPaths=context.config.documents===undefined?null:new Set(
925
+ inspected.browserDocuments.map(function selectedDocumentPath(document){return document.packagePath;})
926
+ );
891
927
  if(pwaEnabled){
892
- const documentPaths=new Set([entryPath]);
893
- for(const selected of context.config.include){
928
+ const documentPaths=new Set(explicitDocumentPaths??[entryPath]);
929
+ for(const selected of explicitDocumentPaths===null?context.config.include:[]){
894
930
  const selectedPath=appPackagePath(context,selected);
895
931
  if(/\.html?$/iu.test(selected)&&inventory.has(selectedPath))documentPaths.add(selectedPath);
896
932
  }
@@ -925,6 +961,9 @@ async function packageWithContext(context,options={}){
925
961
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
926
962
  const pending=[{file:entryPath,documentUrl}];
927
963
  const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
964
+ const applicationFiles=new Set(records.filter(function applicationRecord(record){
965
+ return record.appRelativePath!==undefined;
966
+ }).map(function applicationDestination(record){return record.destination;}));
928
967
  for(const file of files){
929
968
  if((sharedFiles.has(file)||/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file))
930
969
  &&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
@@ -985,7 +1024,9 @@ async function packageWithContext(context,options={}){
985
1024
  if(pwaEnabled&&offlineInventory.has(file)){
986
1025
  offlineReferences.add(versionAssetUrl(`.${target.pathname}${target.search}`,null));
987
1026
  }
988
- if(traversable)pending.push({
1027
+ const selectedDocument=kind!=='document'||!applicationFiles.has(file)||explicitDocumentPaths===null
1028
+ ||explicitDocumentPaths.has(file);
1029
+ if(traversable&&selectedDocument)pending.push({
989
1030
  file,
990
1031
  documentUrl:kind==='document'?target
991
1032
  :baseHref?new URL(baseHref,ownerUrl):current.documentUrl
@@ -1013,6 +1054,7 @@ async function packageWithContext(context,options={}){
1013
1054
  sdkVersion:assetVersion,
1014
1055
  pwa:context.config.pwa,
1015
1056
  files,
1057
+ navigationAliases: rootDocument ? {} : {'./': packageResourceUrl(entryPath)},
1016
1058
  assets:[...offlineReferences]
1017
1059
  }):null;
1018
1060
  if(pwaArtifacts){
@@ -1043,7 +1085,7 @@ async function packageWithContext(context,options={}){
1043
1085
  }
1044
1086
  files.sort(compareText);
1045
1087
  }
1046
- const manifest=releaseManifest(context,files,pwaArtifacts);
1088
+ const manifest=releaseManifest(context,files,pwaArtifacts,rootDocument);
1047
1089
  await writeFile(
1048
1090
  path.join(stagingRoot,RELEASE_MANIFEST_NAME),
1049
1091
  `${JSON.stringify(manifest,null,2)}\n`,
@@ -496,8 +496,10 @@ function installPwaWorker(manifest, clientUrl) {
496
496
 
497
497
  function staticNavigationResource(url) {
498
498
  const document = new URL(url);
499
- if (document.origin !== scopeOrigin || !document.search
500
- || !/\.html?$/iu.test(document.pathname)) {
499
+ if (document.origin !== scopeOrigin) return null;
500
+ if (document.pathname.endsWith('/')) {
501
+ document.pathname += 'index.html';
502
+ } else if (!document.search || !/\.html?$/iu.test(document.pathname)) {
501
503
  return null;
502
504
  }
503
505
  document.search = '';
@@ -195,10 +195,20 @@ npm run run
195
195
 
196
196
  The explicit \`import-map\` command refreshes
197
197
  \`${appPrefix}modules/arcane.importmap.json\` and the managed inline browser
198
- import map in every directly navigable descriptor-admitted \`.html\`/\`.htm\`
199
- document. HTML component fragments remain package files but do not receive a
200
- document-level base or managed import map.
201
- Development, package, and build refresh that shared inventory when the selected operation needs it.
198
+ import map in each selected application \`.html\`/\`.htm\` document.
199
+ Optional \`package.documents\` in \`arcane-app.json\` selects literal app-root-relative
200
+ HTML paths already included and not excluded, for example \`["index.html"]\`.
201
+ The entry is always selected first; \`[]\` selects only the entry. Omission
202
+ retains automatic discovery of the entry and included secondary pages with an
203
+ active base. The schema-1 package projection preserves this as \`documents\`,
204
+ including the distinction between omission and an empty list.
205
+ Selected pages retain their existing path-correct base requirement. With an
206
+ explicit list, other included HTML documents retain their exact content, even
207
+ when referenced by an iframe. Document selection leaves serving, packaging and
208
+ offline resource membership unchanged. HTML component processing and shared
209
+ runtime-resource traversal retain their existing behavior.
210
+ Development startup refreshes the selected managed maps; package and build
211
+ consume the saved source maps and the same document selection.
202
212
  Commit generated import maps and enabled offline app files. Hosting workflows consume those committed files rather than generating them.
203
213
  Named \`${directRuntime?'arcane-os/modules/* and arcane-os/entities/*':'arcane/*'}\` imports resolve through the managed map to the selected SDK files. Packaging copies the complete selected application, runtime, and specifier
204
214
  map to \`dist/${appId}\` without running application tests. Run \`verify\` only when