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.
- package/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/docs/architecture.md +26 -3
- package/docs/reference/cli.md +54 -20
- package/docs/reference/protocols.md +71 -14
- package/docs/reference/pwa.md +74 -9
- package/docs/reference/sdk-api.md +32 -10
- package/package.json +1 -1
- package/schemas/arcane-app.schema.json +5 -0
- package/schemas/arcane-package.schema.json +10 -0
- package/src/app-descriptor.mjs +6 -1
- package/src/dev-server.mjs +204 -43
- package/src/packager/core.mjs +53 -11
- package/src/pwa-worker.mjs +4 -2
- package/src/templates/workspace-template.mjs +14 -4
- package/src/toolchain.mjs +24 -6
package/src/dev-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
1225
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
1269
|
-
|
|
1270
|
-
: undefined
|
|
1430
|
+
lastModified: representation?.lastModified,
|
|
1431
|
+
representationTracked: representation !== undefined
|
|
1271
1432
|
}
|
|
1272
1433
|
);
|
|
1273
1434
|
});
|
package/src/packager/core.mjs
CHANGED
|
@@ -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 (!
|
|
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
|
-
|
|
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`,
|
package/src/pwa-worker.mjs
CHANGED
|
@@ -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
|
|
500
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|