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.
- package/CHANGELOG.md +27 -0
- package/README.md +9 -4
- package/browser-runtime/pwa-install.mjs +3 -3
- package/docs/architecture.md +19 -8
- package/docs/reference/cli.md +18 -7
- package/docs/reference/protocols.md +30 -8
- package/docs/reference/pwa.md +24 -0
- package/package.json +2 -2
- package/runtime/arcane/components/dashboard-config.html +2 -1
- package/runtime/arcane/components/data-view.html +2 -1
- package/runtime/arcane/components/file-manager.html +7 -6
- package/src/app-descriptor.mjs +3 -1
- package/src/app-layout.mjs +32 -0
- package/src/application-tests.mjs +3 -1
- package/src/cli/main.mjs +9 -12
- package/src/dev-server.mjs +82 -24
- package/src/import-map.mjs +26 -10
- package/src/packager/core.mjs +79 -21
- package/src/pwa-worker.mjs +18 -4
- package/src/pwa.mjs +55 -10
- package/src/scaffold.mjs +43 -7
- package/src/sdk-runtime-layout.mjs +28 -15
- package/src/templates/workspace-template.mjs +44 -36
- package/src/toolchain.mjs +76 -3
- package/src/workspace.mjs +46 -38
package/src/dev-server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
481
|
+
startPath:applicationSourcePath(resolved.config,resolved.appId,resolved.app.manifest.entry),
|
|
472
482
|
runtime:sdkSource.runtime,
|
|
473
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
640
|
-
if (
|
|
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.
|
|
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:
|
|
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
|
|
1081
|
+
navigationAliases,
|
|
1047
1082
|
basePath: '/',
|
|
1048
|
-
appBase
|
|
1049
|
-
|
|
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
|
|
1076
|
-
|| targetPath
|
|
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
|
|
1140
|
-
.
|
|
1141
|
-
const
|
|
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
|
|
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.
|
|
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=
|
|
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
|
package/src/import-map.mjs
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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
|
}
|
package/src/packager/core.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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(`${
|
|
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=
|
|
410
|
-
await assertContainedRealPath(appsRoot,appRoot
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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(
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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){
|
package/src/pwa-worker.mjs
CHANGED
|
@@ -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' ?
|
|
400
|
-
if (redirect && cacheUrl(redirect) !== url && ownedUrls.has(
|
|
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' &&
|
|
439
|
+
&& !(request.mode === 'navigate' && navigationRedirect(url))) {
|
|
426
440
|
return;
|
|
427
441
|
}
|
|
428
442
|
const resource = requestedResource(request, url);
|