arcane-os 0.9.0 → 0.10.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.
@@ -1,6 +1,6 @@
1
1
  import Is from 'strong-type';
2
2
  import {constants as FS_CONSTANTS} from 'node:fs';
3
- import {lstat,open,readFile,realpath} from 'node:fs/promises';
3
+ import {lstat,open,readFile,readdir,realpath} from 'node:fs/promises';
4
4
  import http from 'node:http';
5
5
  import https from 'node:https';
6
6
  import os from 'node:os';
@@ -8,9 +8,10 @@ import path from 'node:path';
8
8
  import {resolveWorkspace} from './workspace.mjs';
9
9
  import {createEventQueue} from './event-queue.mjs';
10
10
  import {
11
- inspectImportMapHtml,MANAGED_IMPORT_MAP_ATTRIBUTE,
12
- readWorkspaceAssetVersion,rewriteAssetReferences
11
+ applyPwaEntryReferences,inspectImportMapHtml,MANAGED_IMPORT_MAP_ATTRIBUTE,
12
+ readWorkspaceAssetVersion,rewriteAssetReferences,versionAssetUrl
13
13
  } from './import-map.mjs';
14
+ import {createPwaArtifacts,selectPwaFiles} from './pwa.mjs';
14
15
 
15
16
  const is = new Is(false);
16
17
 
@@ -29,6 +30,7 @@ const MIME_TYPES=new Map([
29
30
  ['.svg','image/svg+xml; charset=utf-8'],
30
31
  ['.txt','text/plain; charset=utf-8'],
31
32
  ['.wasm','application/wasm'],
33
+ ['.webmanifest','application/manifest+json; charset=utf-8'],
32
34
  ['.webp','image/webp'],
33
35
  ['.woff','font/woff'],
34
36
  ['.woff2','font/woff2']
@@ -318,17 +320,25 @@ async function openSafeFile(root,segments){
318
320
  }
319
321
  }
320
322
 
321
- async function sendFile(response,opened,{head=false,assetVersion,revalidate=false,onReference}={}){
323
+ async function sendFile(response,opened,{head=false,assetVersion,revalidate=false,onReference,pwaEntry=false}={}){
322
324
  const extension=path.extname(opened.candidate).toLowerCase();
323
- const content=assetVersion&&/\.(?:m?js|html?|css)$/iu.test(extension)
325
+ let content=assetVersion!==undefined&&/\.(?:m?js|html?|css|json)$/iu.test(extension)
324
326
  ?Buffer.from(rewriteAssetReferences(opened.content.toString('utf8'),{
325
327
  filePath:opened.candidate,
326
328
  version:assetVersion,
327
329
  onReference
328
330
  }),'utf8')
329
331
  :opened.content;
330
- // Entry documents must discover the current generated graph on navigation.
331
- // no-cache permits storage and revalidation; ordinary asset caching is unchanged.
332
+ if (pwaEntry) {
333
+ content = Buffer.from(
334
+ applyPwaEntryReferences(
335
+ content.toString('utf8'),
336
+ {manifestUrl: '/arcane.webmanifest', bootstrapUrl: '/arcane-pwa.mjs'}
337
+ ),
338
+ 'utf8'
339
+ );
340
+ }
341
+ // Stable PWA URLs and entry documents revalidate while retaining HTTP caching.
332
342
  response.writeHead(200,{
333
343
  'content-type':MIME_TYPES.get(extension)||'application/octet-stream',
334
344
  'content-length':content.byteLength,
@@ -402,6 +412,7 @@ async function sourceRoutes(workspaceRoot,appId,{
402
412
  const appMapping={
403
413
  prefix:['apps',resolved.appId],
404
414
  root:resolved.appRoot,
415
+ include:resolved.app.manifest.include,
405
416
  allow:relative=>sourcePathAllowed(relative,resolved.app.manifest)
406
417
  };
407
418
  if(sdkRuntimeSourceRoot!==undefined){
@@ -415,6 +426,7 @@ async function sourceRoutes(workspaceRoot,appId,{
415
426
  workspaceRoot:resolved.workspaceRoot,
416
427
  workspaceMode:resolved.config.workspaceMode,
417
428
  appId:resolved.appId,
429
+ app:resolved.app.manifest,
418
430
  startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
419
431
  runtime:sdkSource.runtime,
420
432
  mappings:[appMapping,...sdkSource.mappings]
@@ -425,12 +437,14 @@ async function sourceRoutes(workspaceRoot,appId,{
425
437
  workspaceRoot:resolved.workspaceRoot,
426
438
  workspaceMode:'integrated',
427
439
  appId:resolved.appId,
440
+ app:resolved.app.manifest,
428
441
  startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
429
442
  mappings:[
430
443
  appMapping,
431
444
  ...resolved.config.sharedPayloads['browser-runtime'].map(route=>({
432
445
  prefix:route.destination.split('/'),
433
446
  root:path.join(resolved.workspaceRoot,...route.source.split('/')),
447
+ include:route.include,
434
448
  allow:relative=>sharedPathAllowed(relative,route)
435
449
  }))
436
450
  ]
@@ -441,12 +455,14 @@ async function sourceRoutes(workspaceRoot,appId,{
441
455
  workspaceRoot:resolved.workspaceRoot,
442
456
  workspaceMode:'external',
443
457
  appId:resolved.appId,
458
+ app:resolved.app.manifest,
444
459
  startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
445
460
  mappings:[
446
461
  appMapping,
447
462
  {
448
463
  prefix:['arcane'],
449
464
  root:runtimeRoot,
465
+ include:[...SDK_INSTALLED_ARCANE_ROOTS],
450
466
  allow:sdkInstalledArcanePathAllowed
451
467
  }
452
468
  ]
@@ -457,10 +473,25 @@ async function packagedRoutes(releaseRoot){
457
473
  if(!is.string(releaseRoot)||!releaseRoot.trim())fail('releaseRoot is required in packaged mode.','ARCANE_USAGE');
458
474
  const requested=path.resolve(releaseRoot);
459
475
  const canonical=await canonicalRealDirectory(requested,'Packaged release root');
476
+ let pwa = false;
477
+ let startPath = '/index.html';
478
+ try {
479
+ const manifest = JSON.parse(await readFile(path.join(canonical, 'arcane-offline.json'), 'utf8'));
480
+ pwa = manifest.schemaVersion === 1;
481
+ if (pwa) {
482
+ const release = JSON.parse(
483
+ await readFile(path.join(canonical, 'ARCANE_APP_RELEASE.json'), 'utf8')
484
+ );
485
+ startPath = `/${release.app.entry}`;
486
+ }
487
+ } catch (error) {
488
+ if (error.code !== 'ENOENT') throw error;
489
+ }
460
490
  return {
461
491
  workspaceRoot:null,
462
492
  appId:null,
463
- startPath:'/index.html',
493
+ pwa,
494
+ startPath,
464
495
  mappings:[{
465
496
  prefix:[],
466
497
  root:canonical
@@ -468,6 +499,135 @@ async function packagedRoutes(releaseRoot){
468
499
  };
469
500
  }
470
501
 
502
+ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourcePaths) {
503
+ const records = new Map();
504
+ const resources = new Map();
505
+ for (const mapping of mappings) {
506
+ const visited = new Set();
507
+ async function visitSourceResource(relative) {
508
+ throwIfAborted(signal);
509
+ const key = relative.join('/');
510
+ if (visited.has(key)) return;
511
+ visited.add(key);
512
+ if (relative.length > 0 && mapping.allow && !mapping.allow(relative)) return;
513
+ const location = path.join(mapping.root, ...relative);
514
+ let info;
515
+ try {
516
+ info = await lstat(location);
517
+ } catch (error) {
518
+ if (error.code === 'ENOENT') return;
519
+ throw error;
520
+ }
521
+ if (info.isDirectory()) {
522
+ const entries = await readdir(location, {withFileTypes: true});
523
+ for (const entry of entries) {
524
+ await visitSourceResource([...relative, entry.name]);
525
+ }
526
+ } else if (info.isFile()) {
527
+ const segments = [...mapping.prefix, ...relative];
528
+ const url = `/${segments.map(encodeURIComponent).join('/')}`;
529
+ const appResource = mapping.prefix[0] === 'apps'
530
+ && mapping.prefix[1] === routeSet.appId;
531
+ const logical = (appResource ? relative : segments).join('/');
532
+ records.set(logical, url);
533
+ resources.set(url, {mapping, relative});
534
+ }
535
+ }
536
+ for (const selected of mapping.include ?? ['']) {
537
+ await visitSourceResource(selected ? selected.split('/') : []);
538
+ }
539
+ }
540
+ const origin = 'http://arcane.invalid';
541
+ const entryUrl = new URL(routeSet.startPath, origin);
542
+ const pending = [{url: entryUrl, documentUrl: entryUrl}];
543
+ const visited = new Set();
544
+ const referencesByPath = new Map();
545
+ for (const selected of routeSet.app.include) {
546
+ if (!/\.html?$/iu.test(selected)) continue;
547
+ const url = new URL(
548
+ `/apps/${routeSet.appId}/${selected.split('/').map(encodeURIComponent).join('/')}`,
549
+ origin
550
+ );
551
+ pending.push({url, documentUrl: url});
552
+ }
553
+ let runtimeRootsAdded = false;
554
+ for (const current of pending) {
555
+ throwIfAborted(signal);
556
+ const pathname = current.url.pathname;
557
+ const record = resources.get(pathname);
558
+ const context = `${pathname}\n${current.documentUrl.origin}${current.documentUrl.pathname}${current.documentUrl.search}`;
559
+ if (!record || visited.has(context)) continue;
560
+ const managedMap = path.posix.basename(pathname) === 'arcane.importmap.json';
561
+ if (!/\.(?:m?js|html?|css)$/iu.test(pathname) && !managedMap) continue;
562
+ visited.add(context);
563
+ resourcePaths.add(decodeURIComponent(pathname));
564
+ let references = referencesByPath.get(pathname);
565
+ if (!references) {
566
+ const opened = await openSafeFile(record.mapping.root, record.relative);
567
+ if (!opened) continue;
568
+ references = [];
569
+ rewriteAssetReferences(
570
+ opened.content.toString('utf8'),
571
+ {
572
+ filePath: opened.candidate,
573
+ version: null,
574
+ onReference: function rememberSourceReference(reference) {
575
+ references.push(reference);
576
+ }
577
+ }
578
+ );
579
+ referencesByPath.set(pathname, references);
580
+ }
581
+ const authoredBase = references.find(function documentBaseReference(reference) {
582
+ return reference.baseHref;
583
+ })?.baseHref;
584
+ const documentUrl = authoredBase ? new URL(authoredBase, current.url) : current.documentUrl;
585
+ if (!runtimeRootsAdded && pathname === entryUrl.pathname) {
586
+ runtimeRootsAdded = true;
587
+ for (const url of resources.keys()) {
588
+ if (/^\/arcane\/.*\.(?:m?js|html?|css)$/iu.test(url)
589
+ || path.posix.basename(url) === 'arcane.importmap.json') {
590
+ pending.push({url: new URL(url, origin), documentUrl});
591
+ }
592
+ }
593
+ }
594
+ for (const {url, kind, baseHref, baseKind} of references) {
595
+ if (kind === 'import' && !/^(?:\.{1,2}\/|\/)/u.test(url)) continue;
596
+ let target;
597
+ let base;
598
+ try {
599
+ base = baseHref ? new URL(baseHref, current.url)
600
+ : managedMap || baseKind === 'document' ? documentUrl : current.url;
601
+ target = new URL(versionAssetUrl(url, null), base);
602
+ } catch {
603
+ // Non-URL source values retain their authored behavior.
604
+ continue;
605
+ }
606
+ if (target.origin !== origin || !resources.has(target.pathname)) continue;
607
+ resourceUrls.add(`${target.pathname}${target.search}`);
608
+ const traversable = kind !== 'fetch'
609
+ && (kind !== 'asset' || /\.css(?:[?#]|$)/iu.test(url));
610
+ if (traversable) {
611
+ pending.push({
612
+ url: target,
613
+ documentUrl: kind === 'document' ? target : documentUrl
614
+ });
615
+ }
616
+ }
617
+ }
618
+ const assets = selectPwaFiles([...records.keys()].sort(), routeSet.app.pwa).map(
619
+ function selectedSourceUrl(relative) {
620
+ return records.get(relative);
621
+ }
622
+ );
623
+ const selectedUrls = new Set(assets);
624
+ for (const resourceUrl of resourceUrls) {
625
+ const url = new URL(resourceUrl, 'http://arcane.invalid');
626
+ if (selectedUrls.has(url.pathname)) selectedUrls.add(resourceUrl);
627
+ }
628
+ return [...selectedUrls].sort();
629
+ }
630
+
471
631
  function listen(server,{host,port,signal}){
472
632
  return new Promise((resolve,reject)=>{
473
633
  const cleanup=()=>{
@@ -598,6 +758,7 @@ async function startOwnedDevServer({
598
758
  })
599
759
  :await packagedRoutes(releaseRoot);
600
760
  const mappings=deterministicMappings(routeSet.mappings);
761
+ const pwaEnabled = mode === 'source' ? routeSet.app?.pwa?.enabled === true : routeSet.pwa;
601
762
  async function selectedAssetVersion(){
602
763
  if(mode!=='source')return undefined;
603
764
  return sdkRuntimeSourceRoot===undefined
@@ -612,6 +773,57 @@ async function startOwnedDevServer({
612
773
  if(info.isSymbolicLink()||!info.isDirectory())fail(`Server route root must be a real directory: ${mapping.root}.`);
613
774
  mapping.root=await realpath(mapping.root);
614
775
  }
776
+ let pwaArtifacts;
777
+ let pwaInventoryTask;
778
+ const pwaResourceUrls = new Set();
779
+ function developmentPwaArtifacts(assets = []) {
780
+ return createPwaArtifacts(
781
+ {
782
+ app: {
783
+ id: routeSet.appId,
784
+ displayName: routeSet.app.displayName,
785
+ version: routeSet.app.version,
786
+ entry: routeSet.startPath
787
+ },
788
+ sdkVersion: assetVersion,
789
+ pwa: routeSet.app.pwa,
790
+ files: [],
791
+ assets,
792
+ navigationAliases: {'/': routeSet.startPath},
793
+ basePath: '/',
794
+ appBase: `/apps/${routeSet.appId}/`,
795
+ runtimeBase: '/arcane/sdk/',
796
+ mode: 'development'
797
+ }
798
+ );
799
+ }
800
+ async function sourcePwaArtifact(targetPath) {
801
+ if (targetPath === '/arcane-sw.js'
802
+ || (targetPath === '/arcane-offline.json' && !pwaArtifacts)) {
803
+ if (!pwaInventoryTask) {
804
+ pwaInventoryTask = Promise.all(
805
+ [sourcePwaAssets(routeSet, mappings, signal, pwaResourceUrls, resourcePaths), selectedAssetVersion()]
806
+ ).then(
807
+ function prepareSourceOfflineInventory([assets, version]) {
808
+ assetVersion = version;
809
+ pwaArtifacts = developmentPwaArtifacts(assets);
810
+ return pwaArtifacts;
811
+ }
812
+ ).finally(
813
+ function releaseSourceInventoryTask() {
814
+ pwaInventoryTask = null;
815
+ }
816
+ );
817
+ }
818
+ await pwaInventoryTask;
819
+ }
820
+ const artifacts = pwaArtifacts ?? developmentPwaArtifacts();
821
+ return artifacts.files.find(
822
+ function requestedPwaFile(file) {
823
+ return `/${file.path}` === targetPath;
824
+ }
825
+ );
826
+ }
615
827
  // Remember actual resource edges as their owners are served, not every
616
828
  // HTML/JS/CSS file in an application's document or attachment corpus.
617
829
  const resourcePaths=new Set([routeSet.startPath]);
@@ -627,6 +839,16 @@ async function startOwnedDevServer({
627
839
  const target=parseRequestTarget(request.url);
628
840
  if(!target){deny(response,400,'Invalid request path.');return;}
629
841
  const {segments}=target;
842
+ if (mode === 'source' && pwaEnabled
843
+ && ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs'].includes(target.path)) {
844
+ const generated = await sourcePwaArtifact(target.path);
845
+ await sendFile(
846
+ response,
847
+ {candidate: generated.path, content: Buffer.from(generated.content, 'utf8')},
848
+ {head: request.method === 'HEAD', revalidate: true}
849
+ );
850
+ return;
851
+ }
630
852
  if(segments.length===0){
631
853
  response.writeHead(302,{location:routeSet.startPath});
632
854
  response.end();
@@ -659,7 +881,10 @@ async function startOwnedDevServer({
659
881
  // An unrelated document is not a source validation surface.
660
882
  }
661
883
  }
662
- const entryDocument=target.path===routeSet.startPath||managedDocument;
884
+ const selectedPwaDocument = mode === 'source' && pwaEnabled && html
885
+ && mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId
886
+ && routeSet.app.include.includes(relative.join('/'));
887
+ const entryDocument=target.path===routeSet.startPath||managedDocument||selectedPwaDocument;
663
888
  const managedMap=path.basename(opened.candidate)==='arcane.importmap.json';
664
889
  // A live server can span an SDK upgrade. Refresh the small
665
890
  // version record on navigation, not on each resource request.
@@ -667,9 +892,22 @@ async function startOwnedDevServer({
667
892
  const runtimeResource=segments[0]==='arcane';
668
893
  const browserResource=['script','style','worker','sharedworker','serviceworker']
669
894
  .includes(request.headers['sec-fetch-dest']);
670
- const rewrite=runtimeResource||entryDocument||browserResource
895
+ const rewrite=runtimeResource||entryDocument||browserResource||managedMap
671
896
  ||resourcePaths.has(target.path);
672
- const onReference=({url,kind,baseHref,baseKind})=>{
897
+ const onReference = function observeServedResource({url,kind,baseHref,baseKind}) {
898
+ if (pwaEnabled && kind !== 'fetch'
899
+ && !(kind === 'import' && !/^(?:\.{1,2}\/|\/)/u.test(url))) {
900
+ try {
901
+ const documentUrl = new URL(target.path, 'http://arcane.invalid');
902
+ const base = baseHref ? new URL(baseHref, documentUrl) : documentUrl;
903
+ const resource = new URL(versionAssetUrl(url, null), base);
904
+ if (resource.origin === documentUrl.origin) {
905
+ pwaResourceUrls.add(`${resource.pathname}${resource.search}`);
906
+ }
907
+ } catch {
908
+ // Non-URL resource values remain under their existing owner.
909
+ }
910
+ }
673
911
  if(baseKind==='document'||kind==='fetch'||(kind==='asset'&&!/\.css(?:[?#]|$)/iu.test(url))
674
912
  ||(kind==='import'&&!/^(?:\.{1,2}\/|\/)/u.test(url)))return;
675
913
  try{
@@ -684,8 +922,9 @@ async function startOwnedDevServer({
684
922
  };
685
923
  await sendFile(response,opened,{
686
924
  head:request.method==='HEAD',
687
- assetVersion:rewrite?assetVersion:undefined,
688
- revalidate:entryDocument||managedMap,
925
+ assetVersion:rewrite&&mode==='source'?(pwaEnabled?null:assetVersion):undefined,
926
+ revalidate:pwaEnabled||entryDocument||managedMap,
927
+ pwaEntry:mode==='source'&&pwaEnabled&&entryDocument,
689
928
  onReference
690
929
  });
691
930
  });