arcane-os 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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 +12 -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 +72 -20
- package/src/import-map.mjs +26 -10
- package/src/packager/core.mjs +69 -19
- package/src/pwa-worker.mjs +18 -4
- package/src/pwa.mjs +2 -1
- 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 +75 -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,12 @@ 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 ? {installationId:`/apps/${routeSet.appId}/`} : {}),
|
|
1085
|
+
runtimeBase: selectedRoutes.browserRuntimeBase
|
|
1086
|
+
?? selectedRoutes.installed?.browserRuntimeBase ?? '/arcane/sdk/',
|
|
1050
1087
|
mode: 'development'
|
|
1051
1088
|
}
|
|
1052
1089
|
);
|
|
@@ -1138,10 +1175,25 @@ async function startOwnedDevServer({
|
|
|
1138
1175
|
const {segments}=target;
|
|
1139
1176
|
const generatedPwaPath = ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs']
|
|
1140
1177
|
.includes(target.path);
|
|
1141
|
-
const
|
|
1178
|
+
const legacyAppRequest = mode === 'source' && routeSet.config.appsRoot === '.'
|
|
1179
|
+
&& segments[0] === 'apps' && segments[1] === routeSet.appId;
|
|
1180
|
+
const requestedMapping = currentSourceRoutes.mappings.find(function currentRequestMapping(route) {
|
|
1181
|
+
return route.prefix.every(function currentRequestSegment(segment,index) {
|
|
1182
|
+
return segments[index] === segment;
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
const appRequest = requestedMapping?.kind === 'app' || legacyAppRequest;
|
|
1142
1186
|
const selectedRoutes = mode === 'source' && (appRequest || segments.length === 0 || generatedPwaPath)
|
|
1143
1187
|
? await refreshSourceRoutes() : currentSourceRoutes;
|
|
1144
1188
|
const pwaEnabled = mode === 'source' ? selectedRoutes.app?.pwa?.enabled === true : routeSet.pwa;
|
|
1189
|
+
if (legacyAppRequest) {
|
|
1190
|
+
const legacyPath = target.pathname.slice(`/apps/${routeSet.appId}`.length);
|
|
1191
|
+
const location = !legacyPath || legacyPath === '/' || legacyPath === '/index.html'
|
|
1192
|
+
? selectedRoutes.startPath : legacyPath;
|
|
1193
|
+
response.writeHead(302,{location:`${location}${target.search}`});
|
|
1194
|
+
response.end();
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1145
1197
|
if (mode === 'source' && pwaEnabled
|
|
1146
1198
|
&& generatedPwaPath) {
|
|
1147
1199
|
const generated = await sourcePwaArtifact(target.path, selectedRoutes);
|
|
@@ -1163,7 +1215,7 @@ async function startOwnedDevServer({
|
|
|
1163
1215
|
return;
|
|
1164
1216
|
}
|
|
1165
1217
|
if(segments.length===0){
|
|
1166
|
-
response.writeHead(302,{location
|
|
1218
|
+
response.writeHead(302,{location:`${selectedRoutes.startPath}${target.search}`});
|
|
1167
1219
|
response.end();
|
|
1168
1220
|
return;
|
|
1169
1221
|
}
|
|
@@ -1195,7 +1247,7 @@ async function startOwnedDevServer({
|
|
|
1195
1247
|
const extension=path.extname(opened.candidate).toLowerCase();
|
|
1196
1248
|
const html=extension==='.html'||extension==='.htm';
|
|
1197
1249
|
const selectedPwaDocument = mode === 'source' && pwaEnabled && html
|
|
1198
|
-
&& mapping.
|
|
1250
|
+
&& mapping.kind === 'app'
|
|
1199
1251
|
&& selectedRoutes.app.include.includes(relative.join('/'));
|
|
1200
1252
|
const selectedDocument = target.path === selectedRoutes.startPath || selectedPwaDocument;
|
|
1201
1253
|
const managedDocument = !selectedDocument && html && await isManagedDocument(opened);
|
|
@@ -1204,7 +1256,7 @@ async function startOwnedDevServer({
|
|
|
1204
1256
|
// A live server can span an SDK upgrade. Refresh the small
|
|
1205
1257
|
// version record on navigation, not on each resource request.
|
|
1206
1258
|
if(entryDocument||managedMap)rememberAssetVersion(await selectedAssetVersion());
|
|
1207
|
-
const runtimeResource=
|
|
1259
|
+
const runtimeResource=mapping.kind!=='app';
|
|
1208
1260
|
const browserResource=['script','style','worker','sharedworker','serviceworker']
|
|
1209
1261
|
.includes(request.headers['sec-fetch-dest']);
|
|
1210
1262
|
const rewrite=runtimeResource||entryDocument||browserResource||managedMap
|
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,12 @@ async function replaceDirectory(stagingRoot,outputRoot){
|
|
|
734
757
|
async function packageWithContext(context,options={}){
|
|
735
758
|
const {signal,onEvent,browserPwa=true}=options;
|
|
736
759
|
const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
|
|
737
|
-
const appPath
|
|
760
|
+
const appPath=appRelativeRoot(context.rootConfig,context.appId);
|
|
738
761
|
const entryPath=appPackagePath(context,context.config.entry);
|
|
739
762
|
const inspected=await inspectContext(context,{signal});
|
|
763
|
+
const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
|
|
764
|
+
context.appId,context.config.entry,inspected.browserDocuments.map(document=>document.path)
|
|
765
|
+
):[];
|
|
740
766
|
if(options.dryRun){
|
|
741
767
|
return {
|
|
742
768
|
appId:context.appId,
|
|
@@ -787,6 +813,18 @@ async function packageWithContext(context,options={}){
|
|
|
787
813
|
}else{
|
|
788
814
|
await copyBase();
|
|
789
815
|
}
|
|
816
|
+
for(const redirect of navigation){
|
|
817
|
+
throwIfAborted(signal);
|
|
818
|
+
const filePath=path.join(stagingRoot,...redirect.path.split('/'));
|
|
819
|
+
try{
|
|
820
|
+
const current=await readFile(filePath,'utf8');
|
|
821
|
+
if(!current.includes('<!-- Arcane root application navigation -->')){
|
|
822
|
+
fail(`Root application navigation would replace package content: ${redirect.path}.`);
|
|
823
|
+
}
|
|
824
|
+
}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
825
|
+
await mkdir(path.dirname(filePath),{recursive:true});
|
|
826
|
+
await writeFile(filePath,redirect.content,'utf8');
|
|
827
|
+
}
|
|
790
828
|
const files=await listOutputFiles(stagingRoot,{signal});
|
|
791
829
|
// Traverse actual browser resources after the adapter finishes. Files
|
|
792
830
|
// included only as application documents retain their original content.
|
|
@@ -807,7 +845,7 @@ async function packageWithContext(context,options={}){
|
|
|
807
845
|
const appDocument=context.config.include.some(function includesAppDocument(selected){
|
|
808
846
|
return sameOrDescendant(document.path,selected);
|
|
809
847
|
});
|
|
810
|
-
if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
|
|
848
|
+
if(appDocument&&(appPath===''||document.managedMaps.length>0)&&inventory.has(document.packagePath)){
|
|
811
849
|
documentPaths.add(document.packagePath);
|
|
812
850
|
}
|
|
813
851
|
}
|
|
@@ -833,8 +871,9 @@ async function packageWithContext(context,options={}){
|
|
|
833
871
|
const documentUrl=entryDocument?.bases[0]?.href
|
|
834
872
|
?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
|
|
835
873
|
const pending=[{file:entryPath,documentUrl}];
|
|
874
|
+
const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
|
|
836
875
|
for(const file of files){
|
|
837
|
-
if(
|
|
876
|
+
if((sharedFiles.has(file)||/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file))
|
|
838
877
|
&&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
|
|
839
878
|
if(pwaEnabled&&path.posix.basename(file)==='arcane.importmap.json'){
|
|
840
879
|
pending.push({file,documentUrl});
|
|
@@ -906,6 +945,15 @@ async function packageWithContext(context,options={}){
|
|
|
906
945
|
if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
|
|
907
946
|
fail(`Package output is missing its entry file: ${context.config.entry}.`);
|
|
908
947
|
}
|
|
948
|
+
const installed=pwaEnabled?await readInstalledSdkLayout(context.workspaceRoot,context.rootConfig):null;
|
|
949
|
+
const navigationAliases=navigation.length?{
|
|
950
|
+
'./':packageResourceUrl(entryPath),
|
|
951
|
+
[`./apps/${context.appId}`]:packageResourceUrl(entryPath),
|
|
952
|
+
[`./apps/${context.appId}/`]:packageResourceUrl(entryPath),
|
|
953
|
+
...Object.fromEntries(navigation.map(redirect=>[
|
|
954
|
+
packageResourceUrl(redirect.path),packageResourceUrl(redirect.target.slice(1))
|
|
955
|
+
]))
|
|
956
|
+
}:undefined;
|
|
909
957
|
const pwaArtifacts=pwaEnabled?createPwaArtifacts({
|
|
910
958
|
app:{
|
|
911
959
|
id:context.appId,
|
|
@@ -914,6 +962,8 @@ async function packageWithContext(context,options={}){
|
|
|
914
962
|
entry:packageResourceUrl(entryPath)
|
|
915
963
|
},
|
|
916
964
|
appPath,
|
|
965
|
+
...(installed?.direct?{runtimeBase:`.${installed.browserRuntimeBase}`} : {}),
|
|
966
|
+
...(navigationAliases?{navigationAliases}:{}),
|
|
917
967
|
sdkVersion:assetVersion,
|
|
918
968
|
pwa:context.config.pwa,
|
|
919
969
|
files,
|
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);
|
package/src/pwa.mjs
CHANGED
|
@@ -202,6 +202,7 @@ export function createPwaArtifacts(
|
|
|
202
202
|
mode = 'release',
|
|
203
203
|
runtimeBase = './arcane/sdk/',
|
|
204
204
|
appBase,
|
|
205
|
+
installationId,
|
|
205
206
|
appPath = '',
|
|
206
207
|
navigationAliases,
|
|
207
208
|
revision
|
|
@@ -218,7 +219,7 @@ export function createPwaArtifacts(
|
|
|
218
219
|
// Relocating app files must not change an existing installed app's default identity.
|
|
219
220
|
const installationBase = appBase ?? (mode === 'development' ? applicationBase : './');
|
|
220
221
|
const manifest = {
|
|
221
|
-
id: installationBase,
|
|
222
|
+
id: installationId ?? installationBase,
|
|
222
223
|
name: app.displayName,
|
|
223
224
|
short_name: app.displayName,
|
|
224
225
|
start_url: manifestUrl(app.entry, appPath ? basePath : applicationBase),
|