arcane-os 0.25.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 +31 -0
- package/README.md +16 -2
- package/browser-runtime/pwa-install.mjs +3 -3
- package/docs/architecture.md +33 -12
- package/docs/reference/cli.md +39 -11
- package/docs/reference/protocols.md +94 -4
- package/docs/reference/pwa.md +21 -0
- package/docs/reference/sdk-api.md +53 -13
- package/package.json +17 -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/runtime/arcane/modules/MailApi.mjs +19 -0
- 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 +86 -24
- package/src/import-map.mjs +57 -14
- package/src/mail-api.mjs +2 -19
- package/src/packager/core.mjs +84 -29
- 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 +112 -0
- package/src/templates/workspace-template.mjs +44 -36
- package/src/toolchain.mjs +75 -3
- package/src/workspace.mjs +54 -38
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,
|
|
@@ -136,12 +138,13 @@ function isGlobLike(value){
|
|
|
136
138
|
return /[*?\[\]{}]/u.test(value);
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
function validatePathList(value,label,{required=false}={}){
|
|
141
|
+
function validatePathList(value,label,{required=false,allowRoot=false}={}){
|
|
140
142
|
if(!is.array(value)||(required&&value.length===0)){
|
|
141
143
|
fail(`${label} must be ${required?'a non-empty':'an'} array of literal relative paths.`);
|
|
142
144
|
}
|
|
143
145
|
const normalized=value.map((entry,index)=>{
|
|
144
|
-
const item=
|
|
146
|
+
const item=allowRoot?normalizeRelativeRoot(entry,`${label}[${index}]`)
|
|
147
|
+
:normalizeRelativePath(entry,`${label}[${index}]`);
|
|
145
148
|
if(isGlobLike(item))fail(`${label}[${index}] must be literal; directories include descendants.`);
|
|
146
149
|
return item;
|
|
147
150
|
});
|
|
@@ -151,7 +154,8 @@ function validatePathList(value,label,{required=false}={}){
|
|
|
151
154
|
if(required){
|
|
152
155
|
for(let left=0;left<normalized.length;left+=1){
|
|
153
156
|
for(let right=left+1;right<normalized.length;right+=1){
|
|
154
|
-
if(
|
|
157
|
+
if(normalized[left]==='.'||normalized[right]==='.'
|
|
158
|
+
||sameOrDescendant(normalized[left],normalized[right])
|
|
155
159
|
||sameOrDescendant(normalized[right],normalized[left])){
|
|
156
160
|
fail(`${label} has overlapping paths: ${normalized[left]} and ${normalized[right]}`);
|
|
157
161
|
}
|
|
@@ -256,7 +260,7 @@ function validateSharedRoute(route,label){
|
|
|
256
260
|
assertOnlyKeys(route,new Set(['source','destination','include','exclude']),label);
|
|
257
261
|
const source=normalizeRelativeRoot(route.source,`${label}.source`);
|
|
258
262
|
const destination=normalizeRelativeRoot(route.destination,`${label}.destination`);
|
|
259
|
-
const include=validatePathList(route.include,`${label}.include`,{required:true});
|
|
263
|
+
const include=validatePathList(route.include,`${label}.include`,{required:true,allowRoot:true});
|
|
260
264
|
const exclude=validatePathList(route.exclude??[],`${label}.exclude`);
|
|
261
265
|
if(source==='.'||source==='apps'||source.startsWith('apps/')
|
|
262
266
|
||source==='dist'||source.startsWith('dist/')||source==='node_modules'
|
|
@@ -273,8 +277,8 @@ function validateSharedRoute(route,label){
|
|
|
273
277
|
export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
|
|
274
278
|
assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads']),ROOT_CONFIG_NAME);
|
|
275
279
|
if(value.schemaVersion!==1)fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
|
|
276
|
-
if(value.appsRoot
|
|
277
|
-
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".`);
|
|
278
282
|
}
|
|
279
283
|
if(!isPlainObject(value.sharedPayloads)){
|
|
280
284
|
fail(`${ROOT_CONFIG_NAME}.sharedPayloads must be an object.`);
|
|
@@ -289,7 +293,7 @@ export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
|
|
|
289
293
|
validateSharedRoute(route,`sharedPayloads.${id}[${index}]`)
|
|
290
294
|
);
|
|
291
295
|
}
|
|
292
|
-
return {schemaVersion:1,appsRoot:
|
|
296
|
+
return {schemaVersion:1,appsRoot:value.appsRoot,distRoot:'dist',sharedPayloads,configPath};
|
|
293
297
|
}
|
|
294
298
|
|
|
295
299
|
function normalizeOptionalRecord(value,label){
|
|
@@ -298,14 +302,14 @@ function normalizeOptionalRecord(value,label){
|
|
|
298
302
|
return copyJson(value);
|
|
299
303
|
}
|
|
300
304
|
|
|
301
|
-
export function validateAppConfig(value,appId,rootConfig,configPath
|
|
305
|
+
export function validateAppConfig(value,appId,rootConfig,configPath=path.posix.join(appRelativeRoot(rootConfig,appId),APP_CONFIG_NAME)){
|
|
302
306
|
assertOnlyKeys(value,new Set([
|
|
303
307
|
'schemaVersion','id','displayName','version','entry','strategy','security',
|
|
304
308
|
'localAIModelPolicy','include','exclude','shared','adapter','pwa'
|
|
305
309
|
]),`${appId}/${APP_CONFIG_NAME}`);
|
|
306
310
|
if(value.schemaVersion!==1)fail(`${appId}/${APP_CONFIG_NAME}.schemaVersion must be 1.`);
|
|
307
|
-
if(value.id!==appId||!APP_ID_PATTERN.test(value.id)){
|
|
308
|
-
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)}.`);
|
|
309
313
|
}
|
|
310
314
|
const displayName=assertPresentationText(value.displayName,`${appId}/${APP_CONFIG_NAME}.displayName`);
|
|
311
315
|
parseSemver(value.version);
|
|
@@ -404,8 +408,8 @@ async function loadContext(requestedWorkspaceRoot,appId){
|
|
|
404
408
|
const rootConfig=validateRootConfig(await readJson(rootConfigPath,ROOT_CONFIG_NAME),rootConfigPath);
|
|
405
409
|
if(!is.string(appId)||!APP_ID_PATTERN.test(appId))fail(`Unsafe app id: ${String(appId)}`);
|
|
406
410
|
const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
|
|
407
|
-
const appRoot=
|
|
408
|
-
await assertContainedRealPath(appsRoot,appRoot
|
|
411
|
+
const appRoot=resolveAppRoot(workspaceRoot,rootConfig,appId);
|
|
412
|
+
await assertContainedRealPath(appsRoot,appRoot,appId);
|
|
409
413
|
const configPath=path.join(appRoot,APP_CONFIG_NAME);
|
|
410
414
|
const config=validateAppConfig(await readJson(configPath,`${appId}/${APP_CONFIG_NAME}`),appId,rootConfig,configPath);
|
|
411
415
|
return {
|
|
@@ -421,11 +425,12 @@ async function loadContext(requestedWorkspaceRoot,appId){
|
|
|
421
425
|
}
|
|
422
426
|
|
|
423
427
|
function destinationJoin(root,relative){
|
|
424
|
-
return root==='.'?relative:`${root}/${relative}`;
|
|
428
|
+
return relative==='.'?root:root==='.'?relative:`${root}/${relative}`;
|
|
425
429
|
}
|
|
426
430
|
|
|
427
431
|
function appPackagePath(context, relative) {
|
|
428
|
-
|
|
432
|
+
const root=appRelativeRoot(context.rootConfig,context.appId);
|
|
433
|
+
return root?`${root}/${relative}`:relative;
|
|
429
434
|
}
|
|
430
435
|
|
|
431
436
|
function packageResourceUrl(relative) {
|
|
@@ -442,12 +447,13 @@ async function collectSelectedPath({
|
|
|
442
447
|
records,
|
|
443
448
|
destinations,
|
|
444
449
|
signal,
|
|
445
|
-
label
|
|
450
|
+
label,
|
|
451
|
+
allowRoot=false
|
|
446
452
|
}){
|
|
447
453
|
throwIfAborted(signal);
|
|
448
454
|
if(isExcluded(selected,excludes))return;
|
|
449
455
|
if(reject(selected))fail(`${label} selects a reserved private or generated path: ${selected}.`);
|
|
450
|
-
const absolute=resolveInside(sourceRoot,selected,label);
|
|
456
|
+
const absolute=resolveInside(sourceRoot,selected,label,{allowRoot});
|
|
451
457
|
let info;
|
|
452
458
|
try{info=await lstat(absolute);}
|
|
453
459
|
catch(error){
|
|
@@ -455,15 +461,16 @@ async function collectSelectedPath({
|
|
|
455
461
|
throw error;
|
|
456
462
|
}
|
|
457
463
|
if(info.isSymbolicLink())fail(`${label} contains a symbolic link or junction: ${selected}.`);
|
|
464
|
+
if(selected==='.'&&!info.isDirectory())fail(`${label} root selection must be a directory.`);
|
|
458
465
|
if(info.isDirectory()){
|
|
459
466
|
const entries=await readdir(absolute,{withFileTypes:true});
|
|
460
467
|
entries.sort((left,right)=>compareText(left.name,right.name));
|
|
461
468
|
for(const entry of entries){
|
|
462
|
-
const child
|
|
469
|
+
const child=destinationJoin(selected,entry.name);
|
|
463
470
|
await collectSelectedPath({
|
|
464
471
|
sourceRoot,
|
|
465
472
|
selected:child,
|
|
466
|
-
destination
|
|
473
|
+
destination:destinationJoin(destination,entry.name),
|
|
467
474
|
excludes,
|
|
468
475
|
reject,
|
|
469
476
|
records,
|
|
@@ -498,9 +505,13 @@ async function collectPackageRecords(context,{signal}={}){
|
|
|
498
505
|
records,
|
|
499
506
|
destinations,
|
|
500
507
|
signal,
|
|
501
|
-
label
|
|
508
|
+
label:context.appId
|
|
502
509
|
});
|
|
503
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
|
+
}
|
|
504
515
|
for(const sharedId of context.config.shared){
|
|
505
516
|
for(const route of context.rootConfig.sharedPayloads[sharedId]){
|
|
506
517
|
const sourceRoot=resolveInside(context.workspaceRoot,route.source,`sharedPayloads.${sharedId}.source`);
|
|
@@ -515,7 +526,8 @@ async function collectPackageRecords(context,{signal}={}){
|
|
|
515
526
|
records,
|
|
516
527
|
destinations,
|
|
517
528
|
signal,
|
|
518
|
-
label:`sharedPayloads.${sharedId}
|
|
529
|
+
label:`sharedPayloads.${sharedId}`,
|
|
530
|
+
allowRoot:true
|
|
519
531
|
});
|
|
520
532
|
}
|
|
521
533
|
}
|
|
@@ -527,19 +539,19 @@ async function collectPackageRecords(context,{signal}={}){
|
|
|
527
539
|
return records;
|
|
528
540
|
}
|
|
529
541
|
|
|
530
|
-
async function browserDocuments(records,entry
|
|
542
|
+
async function browserDocuments(records,entry){
|
|
531
543
|
let entryDocument=null;
|
|
532
544
|
const documents=[];
|
|
533
545
|
for(const record of records){
|
|
546
|
+
if(record.appRelativePath===undefined)continue;
|
|
534
547
|
const extension=path.posix.extname(record.destination).toLocaleLowerCase('en-US');
|
|
535
548
|
if(extension!=='.html'&&extension!=='.htm')continue;
|
|
536
|
-
const documentPath=record.
|
|
537
|
-
?record.destination.slice(appPrefix.length):record.destination;
|
|
549
|
+
const documentPath=record.appRelativePath;
|
|
538
550
|
const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
|
|
539
551
|
documentPath
|
|
540
552
|
});
|
|
541
553
|
const document={path:documentPath,packagePath:record.destination,...copyJson(inspected)};
|
|
542
|
-
if(
|
|
554
|
+
if(documentPath===entry){
|
|
543
555
|
entryDocument=document;
|
|
544
556
|
}else if(inspected.bases.length>0){
|
|
545
557
|
documents.push(document);
|
|
@@ -562,6 +574,16 @@ async function optionalDescriptor(context){
|
|
|
562
574
|
|
|
563
575
|
async function inspectContext(context,{signal}={}){
|
|
564
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
|
+
}
|
|
565
587
|
return {
|
|
566
588
|
appId:context.appId,
|
|
567
589
|
displayName:context.config.displayName,
|
|
@@ -578,8 +600,8 @@ async function inspectContext(context,{signal}={}){
|
|
|
578
600
|
}),
|
|
579
601
|
...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
|
|
580
602
|
descriptor:await optionalDescriptor(context),
|
|
581
|
-
browserDocuments:
|
|
582
|
-
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),
|
|
583
605
|
output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
|
|
584
606
|
};
|
|
585
607
|
}
|
|
@@ -590,6 +612,12 @@ export async function discoverApps({workspaceRoot:requestedWorkspaceRoot}={}){
|
|
|
590
612
|
await readJson(path.join(workspaceRoot,ROOT_CONFIG_NAME),ROOT_CONFIG_NAME),
|
|
591
613
|
path.join(workspaceRoot,ROOT_CONFIG_NAME)
|
|
592
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
|
+
}
|
|
593
621
|
const appsRoot=await realDirectory(path.join(workspaceRoot,rootConfig.appsRoot),'Apps root');
|
|
594
622
|
const entries=await readdir(appsRoot,{withFileTypes:true});
|
|
595
623
|
const apps=[];
|
|
@@ -729,9 +757,12 @@ async function replaceDirectory(stagingRoot,outputRoot){
|
|
|
729
757
|
async function packageWithContext(context,options={}){
|
|
730
758
|
const {signal,onEvent,browserPwa=true}=options;
|
|
731
759
|
const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
|
|
732
|
-
const appPath
|
|
760
|
+
const appPath=appRelativeRoot(context.rootConfig,context.appId);
|
|
733
761
|
const entryPath=appPackagePath(context,context.config.entry);
|
|
734
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
|
+
):[];
|
|
735
766
|
if(options.dryRun){
|
|
736
767
|
return {
|
|
737
768
|
appId:context.appId,
|
|
@@ -782,6 +813,18 @@ async function packageWithContext(context,options={}){
|
|
|
782
813
|
}else{
|
|
783
814
|
await copyBase();
|
|
784
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
|
+
}
|
|
785
828
|
const files=await listOutputFiles(stagingRoot,{signal});
|
|
786
829
|
// Traverse actual browser resources after the adapter finishes. Files
|
|
787
830
|
// included only as application documents retain their original content.
|
|
@@ -802,7 +845,7 @@ async function packageWithContext(context,options={}){
|
|
|
802
845
|
const appDocument=context.config.include.some(function includesAppDocument(selected){
|
|
803
846
|
return sameOrDescendant(document.path,selected);
|
|
804
847
|
});
|
|
805
|
-
if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
|
|
848
|
+
if(appDocument&&(appPath===''||document.managedMaps.length>0)&&inventory.has(document.packagePath)){
|
|
806
849
|
documentPaths.add(document.packagePath);
|
|
807
850
|
}
|
|
808
851
|
}
|
|
@@ -828,8 +871,9 @@ async function packageWithContext(context,options={}){
|
|
|
828
871
|
const documentUrl=entryDocument?.bases[0]?.href
|
|
829
872
|
?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
|
|
830
873
|
const pending=[{file:entryPath,documentUrl}];
|
|
874
|
+
const sharedFiles=new Set(records.filter(record=>record.appRelativePath===undefined).map(record=>record.destination));
|
|
831
875
|
for(const file of files){
|
|
832
|
-
if(
|
|
876
|
+
if((sharedFiles.has(file)||/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file))
|
|
833
877
|
&&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
|
|
834
878
|
if(pwaEnabled&&path.posix.basename(file)==='arcane.importmap.json'){
|
|
835
879
|
pending.push({file,documentUrl});
|
|
@@ -901,6 +945,15 @@ async function packageWithContext(context,options={}){
|
|
|
901
945
|
if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
|
|
902
946
|
fail(`Package output is missing its entry file: ${context.config.entry}.`);
|
|
903
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;
|
|
904
957
|
const pwaArtifacts=pwaEnabled?createPwaArtifacts({
|
|
905
958
|
app:{
|
|
906
959
|
id:context.appId,
|
|
@@ -909,6 +962,8 @@ async function packageWithContext(context,options={}){
|
|
|
909
962
|
entry:packageResourceUrl(entryPath)
|
|
910
963
|
},
|
|
911
964
|
appPath,
|
|
965
|
+
...(installed?.direct?{runtimeBase:`.${installed.browserRuntimeBase}`} : {}),
|
|
966
|
+
...(navigationAliases?{navigationAliases}:{}),
|
|
912
967
|
sdkVersion:assetVersion,
|
|
913
968
|
pwa:context.config.pwa,
|
|
914
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),
|
package/src/scaffold.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
|
|
|
8
8
|
import {SDK_NAME,SDK_VERSION,workspaceTemplate} from './templates/workspace-template.mjs';
|
|
9
9
|
import {inspectWorkspaceProfile,resolveSdkPackageDeclaration} from './workspace.mjs';
|
|
10
10
|
import {parseSemver} from './packager/core.mjs';
|
|
11
|
+
import {resolveAppRoot} from './app-layout.mjs';
|
|
11
12
|
|
|
12
13
|
const is = new Is(false);
|
|
13
14
|
|
|
@@ -310,6 +311,7 @@ async function runGitInit(workspaceRoot,signal,onEvent){
|
|
|
310
311
|
export async function createWorkspace({
|
|
311
312
|
targetPath,
|
|
312
313
|
appId,
|
|
314
|
+
appsRoot='apps',
|
|
313
315
|
displayName,
|
|
314
316
|
target='browser',
|
|
315
317
|
initializeGit=false,
|
|
@@ -318,6 +320,7 @@ export async function createWorkspace({
|
|
|
318
320
|
}){
|
|
319
321
|
validateInputs(appId,displayName);
|
|
320
322
|
validateScaffoldTarget(target);
|
|
323
|
+
if(!['apps','.'].includes(appsRoot))fail('appsRoot must be apps or .','ARCANE_USAGE');
|
|
321
324
|
if(!is.string(targetPath)||!targetPath.trim())fail('targetPath is required.','ARCANE_USAGE');
|
|
322
325
|
if(!is.boolean(initializeGit))fail('initializeGit must be a boolean.','ARCANE_USAGE');
|
|
323
326
|
throwIfAborted(signal);
|
|
@@ -332,20 +335,21 @@ export async function createWorkspace({
|
|
|
332
335
|
},async workspaceOperationLease=>{
|
|
333
336
|
const template=workspaceTemplate({
|
|
334
337
|
appId,
|
|
338
|
+
appsRoot,
|
|
335
339
|
displayName,
|
|
336
340
|
target,
|
|
337
341
|
appIcon:await scaffoldIcon(target)
|
|
338
342
|
});
|
|
339
343
|
const writtenFiles=await writeMissingFiles(workspaceRoot,template.files,{signal,onEvent});
|
|
340
|
-
const workspaceRuntime=await materializeWorkspaceRuntimeContent({
|
|
344
|
+
const workspaceRuntime=appsRoot==='.'?null:await materializeWorkspaceRuntimeContent({
|
|
341
345
|
workspaceRoot,
|
|
342
346
|
signal,
|
|
343
347
|
onEvent
|
|
344
348
|
});
|
|
345
|
-
const importMap=await generateImportMap({
|
|
349
|
+
const importMap=appsRoot==='.'?{pending:true,reason:'sdk-install-required'}:await generateImportMap({
|
|
346
350
|
workspaceRoot,
|
|
347
351
|
appId,
|
|
348
|
-
appRoot:
|
|
352
|
+
appRoot:resolveAppRoot(workspaceRoot,{appsRoot},appId),
|
|
349
353
|
workspaceOperationLease,
|
|
350
354
|
signal,
|
|
351
355
|
onEvent
|
|
@@ -354,6 +358,7 @@ export async function createWorkspace({
|
|
|
354
358
|
const result={
|
|
355
359
|
workspaceRoot,
|
|
356
360
|
appId,
|
|
361
|
+
appsRoot,
|
|
357
362
|
displayName:template.name,
|
|
358
363
|
target,
|
|
359
364
|
...writtenFiles,
|
|
@@ -370,12 +375,13 @@ export async function createWorkspace({
|
|
|
370
375
|
export async function initWorkspace({
|
|
371
376
|
workspaceRoot=process.cwd(),
|
|
372
377
|
appId,
|
|
378
|
+
appsRoot,
|
|
373
379
|
displayName,
|
|
374
380
|
target='browser',
|
|
375
381
|
signal,
|
|
376
382
|
onEvent
|
|
377
383
|
}){
|
|
378
|
-
validateInputs(appId,displayName);
|
|
384
|
+
if(appId!==undefined)validateInputs(appId,displayName);
|
|
379
385
|
validateScaffoldTarget(target);
|
|
380
386
|
throwIfAborted(signal);
|
|
381
387
|
const resolvedRoot=path.resolve(workspaceRoot);
|
|
@@ -389,6 +395,28 @@ export async function initWorkspace({
|
|
|
389
395
|
},async workspaceOperationLease=>{
|
|
390
396
|
const profile=await existingWorkspaceProfile(resolvedRoot);
|
|
391
397
|
const workspaceMode=profile?.workspaceMode??'external';
|
|
398
|
+
const selectedAppsRoot=appsRoot??profile?.config.appsRoot??'apps';
|
|
399
|
+
if(!['apps','.'].includes(selectedAppsRoot))fail('appsRoot must be apps or .','ARCANE_USAGE');
|
|
400
|
+
if(profile&&selectedAppsRoot!==profile.config.appsRoot){
|
|
401
|
+
fail('appsRoot must match the existing workspace layout; init does not relocate an application.','ARCANE_USAGE');
|
|
402
|
+
}
|
|
403
|
+
if(workspaceMode==='integrated'&&selectedAppsRoot==='.'){
|
|
404
|
+
fail('The integrated workspace keeps applications under apps; root scaffolding selects a standalone workspace.','ARCANE_USAGE');
|
|
405
|
+
}
|
|
406
|
+
if(selectedAppsRoot==='.'){
|
|
407
|
+
let existingId;
|
|
408
|
+
try{existingId=JSON.parse(await readFile(path.join(resolvedRoot,'arcane-package.json'),'utf8')).id;}
|
|
409
|
+
catch(error){if(error?.code!=='ENOENT')throw error;}
|
|
410
|
+
if(existingId!==undefined){
|
|
411
|
+
if(appId!==undefined&&appId!==existingId){
|
|
412
|
+
fail(`The root application id is ${existingId}; init does not replace its identity.`,'ARCANE_USAGE');
|
|
413
|
+
}
|
|
414
|
+
appId=existingId;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
appId??=path.basename(resolvedRoot).normalize('NFKD').toLowerCase()
|
|
418
|
+
.replace(/[^a-z0-9]+/gu,'-').replace(/^-+|-+$/gu,'')||'arcane-app';
|
|
419
|
+
validateInputs(appId,displayName);
|
|
392
420
|
const existingPackage=workspaceMode==='external'
|
|
393
421
|
?await readExistingPackage(resolvedRoot)
|
|
394
422
|
:null;
|
|
@@ -415,6 +443,7 @@ export async function initWorkspace({
|
|
|
415
443
|
})
|
|
416
444
|
:workspaceTemplate({
|
|
417
445
|
appId,
|
|
446
|
+
appsRoot:selectedAppsRoot,
|
|
418
447
|
displayName,
|
|
419
448
|
sdkDependencyName:sdkDeclaration.dependencyName,
|
|
420
449
|
sdkDependencySpecifier:sdkDeclaration.specifier,
|
|
@@ -451,17 +480,23 @@ export async function initWorkspace({
|
|
|
451
480
|
await emit(onEvent,{type:'scaffold.file.replaced',path:'arcane.lock.json'});
|
|
452
481
|
}
|
|
453
482
|
const packageUpdated=await applyPackageMerge(resolvedRoot,packagePlan,{signal,onEvent});
|
|
454
|
-
const
|
|
483
|
+
const directRuntime=workspaceMode==='external'&&selectedAppsRoot==='.';
|
|
484
|
+
const workspaceRuntime=workspaceMode==='external'&&!directRuntime
|
|
455
485
|
?await materializeWorkspaceRuntimeContent({
|
|
456
486
|
workspaceRoot:resolvedRoot,
|
|
457
487
|
signal,
|
|
458
488
|
onEvent
|
|
459
489
|
})
|
|
460
490
|
:null;
|
|
461
|
-
|
|
491
|
+
let sdkInstalled=true;
|
|
492
|
+
if(directRuntime){
|
|
493
|
+
try{await lstat(path.join(resolvedRoot,sdkDeclaration.packageSource,'package.json'));}
|
|
494
|
+
catch(error){if(error?.code==='ENOENT')sdkInstalled=false;else throw error;}
|
|
495
|
+
}
|
|
496
|
+
const importMap=!sdkInstalled?{pending:true,reason:'sdk-install-required'}:await generateImportMap({
|
|
462
497
|
workspaceRoot:resolvedRoot,
|
|
463
498
|
appId,
|
|
464
|
-
appRoot:
|
|
499
|
+
appRoot:resolveAppRoot(resolvedRoot,{appsRoot:selectedAppsRoot},appId),
|
|
465
500
|
workspaceOperationLease,
|
|
466
501
|
signal,
|
|
467
502
|
onEvent
|
|
@@ -470,6 +505,7 @@ export async function initWorkspace({
|
|
|
470
505
|
workspaceRoot:resolvedRoot,
|
|
471
506
|
workspaceMode,
|
|
472
507
|
appId,
|
|
508
|
+
appsRoot:selectedAppsRoot,
|
|
473
509
|
displayName:template.name,
|
|
474
510
|
target,
|
|
475
511
|
...writtenFiles,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import {readFile,readdir} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const is=new Is(false);
|
|
6
|
+
|
|
7
|
+
// Direct routes use the installed npm paths in both the browser and selected output.
|
|
8
|
+
export function installedSdkRoutes(packageSource,{security=false,direct=false}={}){
|
|
9
|
+
const routes=[
|
|
10
|
+
{
|
|
11
|
+
source:`${packageSource}/runtime/arcane`,destination:'arcane',
|
|
12
|
+
include:['components','css','entities','img','modules',...(security?['security']:[])],exclude:[]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source:`${packageSource}/browser-runtime`,destination:'arcane/sdk',
|
|
16
|
+
include:['.'],exclude:[]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
source:`${packageSource}/runtime/strong-type`,destination:'arcane/dependencies/strong-type',
|
|
20
|
+
include:['.'],exclude:[]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
source:packageSource,destination:'licenses/arcane-os',
|
|
24
|
+
include:['LICENSE','COMMERCIAL-LICENSE.md','NOTICE'],exclude:[]
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
return direct?routes.map(route=>({...route,destination:route.source})):routes;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function installedSdkPackageSource(config){
|
|
31
|
+
const routes=config?.sharedPayloads?.['browser-runtime'];
|
|
32
|
+
if(!is.array(routes)||routes.length!==4)return null;
|
|
33
|
+
const source=routes[3]?.source;
|
|
34
|
+
if(!is.string(source)||!/^node_modules\/(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/u.test(source))return null;
|
|
35
|
+
const expected=installedSdkRoutes(source,{
|
|
36
|
+
security:routes[0]?.include?.at(-1)==='security',
|
|
37
|
+
direct:routes[0]?.destination===routes[0]?.source
|
|
38
|
+
});
|
|
39
|
+
return routes.every(function matchesInstalledRoute(route,index){
|
|
40
|
+
const wanted=expected[index];
|
|
41
|
+
return route?.source===wanted.source&&route?.destination===wanted.destination
|
|
42
|
+
&&is.array(route.include)&&route.include.length===wanted.include.length
|
|
43
|
+
&&route.include.every(function matchesSelectedPath(value,item){return value===wanted.include[item];})
|
|
44
|
+
&&(route.exclude===undefined||(is.array(route.exclude)&&route.exclude.length===0));
|
|
45
|
+
})?source:null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readInstalledSdkLayout(workspaceRoot,config){
|
|
49
|
+
if(config===undefined){
|
|
50
|
+
try{config=JSON.parse(await readFile(path.join(workspaceRoot,'arcane-packager.json'),'utf8'));}
|
|
51
|
+
catch(error){if(error.code==='ENOENT')return null;throw error;}
|
|
52
|
+
}
|
|
53
|
+
const packageSource=installedSdkPackageSource(config);
|
|
54
|
+
if(packageSource===null)return null;
|
|
55
|
+
const packageRoot=path.join(workspaceRoot,...packageSource.split('/'));
|
|
56
|
+
const versionPath=path.join(packageRoot,'package.json');
|
|
57
|
+
const manifest=JSON.parse(await readFile(versionPath,'utf8'));
|
|
58
|
+
if(manifest.name!=='arcane-os'||!is.string(manifest.version)||!manifest.version){
|
|
59
|
+
const error=new Error('The installed SDK runtime must identify its arcane-os package version.');
|
|
60
|
+
error.code='ARCANE_WORKSPACE_INVALID';
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const routes=config.sharedPayloads['browser-runtime'];
|
|
64
|
+
return {
|
|
65
|
+
packageSource,packageRoot,versionPath,version:manifest.version,routes,
|
|
66
|
+
direct:routes[0].destination===routes[0].source,
|
|
67
|
+
runtimeBase:`/${routes[0].destination}/`,
|
|
68
|
+
browserRuntimeBase:`/${routes[1].destination}/`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function installedRuntimeFiles(workspaceRoot,layout,signal){
|
|
73
|
+
const files=[];
|
|
74
|
+
async function visit(directory,logical){
|
|
75
|
+
signal?.throwIfAborted();
|
|
76
|
+
const entries=await readdir(directory,{withFileTypes:true});
|
|
77
|
+
for(const entry of entries){
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
const relative=logical?`${logical}/${entry.name}`:entry.name;
|
|
80
|
+
if(entry.isDirectory())await visit(path.join(directory,entry.name),relative);
|
|
81
|
+
else if(entry.isFile())files.push(relative);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for(const [index,route] of layout.routes.entries()){
|
|
85
|
+
if(index===3)continue;
|
|
86
|
+
const prefix=['','sdk','dependencies/strong-type'][index];
|
|
87
|
+
for(const selected of route.include){
|
|
88
|
+
const suffix=selected==='.'?'':selected;
|
|
89
|
+
await visit(
|
|
90
|
+
path.join(workspaceRoot,...route.source.split('/'),suffix),
|
|
91
|
+
[prefix,suffix].filter(Boolean).join('/')
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {files:files.sort()};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function installedRuntimeTarget(relative,layout,{browser=false}={}){
|
|
99
|
+
const logicalRoots=['arcane','arcane/sdk','arcane/dependencies/strong-type','licenses/arcane-os'];
|
|
100
|
+
const routes=layout.routes.map((route,index)=>({...route,logical:logicalRoots[index]}));
|
|
101
|
+
for(const route of routes.sort(function longestDestinationFirst(left,right){
|
|
102
|
+
return right.logical.length-left.logical.length;
|
|
103
|
+
})){
|
|
104
|
+
if(!relative.startsWith(`${route.logical}/`))continue;
|
|
105
|
+
const suffix=relative.slice(route.logical.length+1);
|
|
106
|
+
if(route.include.some(function includesRuntimeTarget(selected){
|
|
107
|
+
const pathname=suffix.split(/[?#]/u)[0];
|
|
108
|
+
return selected==='.'||pathname===selected||pathname.startsWith(`${selected}/`);
|
|
109
|
+
}))return `${browser?route.destination:route.source}/${suffix}`;
|
|
110
|
+
}
|
|
111
|
+
return relative;
|
|
112
|
+
}
|