arcane-os 0.11.3 → 0.12.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.
@@ -1,7 +1,7 @@
1
1
  import Is from 'strong-type';
2
+ import {Server} from 'node-http-server';
2
3
  import {constants as FS_CONSTANTS} from 'node:fs';
3
4
  import {lstat,open,readFile,readdir,realpath} from 'node:fs/promises';
4
- import http from 'node:http';
5
5
  import https from 'node:https';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
@@ -70,6 +70,7 @@ function throwIfAborted(signal){
70
70
  }
71
71
 
72
72
  function deny(response,status,message){
73
+ response.removeHeader('Last-Modified');
73
74
  response.writeHead(status,{
74
75
  'content-type':'text/plain; charset=utf-8'
75
76
  });
@@ -276,7 +277,7 @@ function createFileWorkLimiter(){
276
277
  return async work=>work();
277
278
  }
278
279
 
279
- async function openSafeFile(root,segments){
280
+ async function openSafeFile(root, segments, {readContent = true} = {}) {
280
281
  const candidate=resolveInside(root,segments);
281
282
  if(!candidate)return null;
282
283
  let current=root;
@@ -305,13 +306,13 @@ async function openSafeFile(root,segments){
305
306
  try{
306
307
  const opened=await handle.stat();
307
308
  if(!opened.isFile())return null;
308
- const content=await handle.readFile();
309
+ const content = readContent ? await handle.readFile() : undefined;
309
310
  const servedCandidate=await realpath(candidate);
310
311
  if(!pathIsWithin(root,servedCandidate)
311
312
  ||canonicalLocationKey(servedCandidate)!==canonicalLocationKey(canonicalCandidate)){
312
313
  return null;
313
314
  }
314
- return {candidate:servedCandidate,content};
315
+ return {candidate: servedCandidate, content, modifiedAt: opened.mtime};
315
316
  }catch(error){
316
317
  if(error?.code==='ENOENT')return null;
317
318
  throw error;
@@ -320,54 +321,87 @@ async function openSafeFile(root,segments){
320
321
  }
321
322
  }
322
323
 
323
- async function sendFile(response,opened,{head=false,assetVersion,revalidate=false,onReference,pwaEntry=false}={}){
324
- const extension=path.extname(opened.candidate).toLowerCase();
325
- let content=assetVersion!==undefined&&/\.(?:m?js|html?|css|json)$/iu.test(extension)
326
- ?Buffer.from(rewriteAssetReferences(opened.content.toString('utf8'),{
327
- filePath:opened.candidate,
328
- version:assetVersion,
329
- onReference
330
- }),'utf8')
331
- :opened.content;
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
- );
324
+ async function serveGeneratedRepresentation(fileServer, request, response, {
325
+ lastModified, contentType, body
326
+ }) {
327
+ const modified = lastModified.toUTCString();
328
+ response.setHeader('Last-Modified', modified);
329
+ const requested = Date.parse(request.headers['if-modified-since']);
330
+ if (Number.isFinite(requested) && Date.parse(modified) <= requested) {
331
+ response.writeHead(304);
332
+ response.end();
333
+ return;
340
334
  }
341
- // Stable PWA URLs and entry documents revalidate while retaining HTTP caching.
342
- response.writeHead(200,{
343
- 'content-type':MIME_TYPES.get(extension)||'application/octet-stream',
344
- 'content-length':content.byteLength,
345
- ...(revalidate?{'cache-control':'no-cache'}:{})
346
- });
347
- await new Promise((resolve,reject)=>{
348
- let settled=false;
349
- const cleanup=()=>{
350
- response.removeListener('error',failed);
351
- response.removeListener('finish',completed);
352
- response.removeListener('close',completed);
353
- };
354
- const completed=()=>{
355
- if(settled)return;
356
- settled=true;
357
- cleanup();
358
- resolve();
359
- };
360
- const failed=error=>{
361
- if(settled)return;
362
- settled=true;
363
- cleanup();
364
- reject(error);
335
+ response.setHeader('Content-Type', contentType);
336
+ await fileServer.serve(request, response, await body());
337
+ }
338
+
339
+ async function serveSourceFile(fileServer, request, response, opened, {
340
+ assetVersion, revalidate = false, onReference, pwaEntry = false, lastModified
341
+ } = {}) {
342
+ const extension = path.extname(opened.candidate).toLowerCase();
343
+ const rewrite = assetVersion !== undefined && /\.(?:m?js|html?|css|json)$/iu.test(extension);
344
+ if (revalidate) response.setHeader('Cache-Control', 'no-cache');
345
+ if (!rewrite && !pwaEntry) {
346
+ // Preserve the SDK's response headers while the module owns static
347
+ // streaming and conditional requests, including its early 304 path.
348
+ const writeHead = response.writeHead;
349
+ response.writeHead = function writeDevelopmentHeaders(...arguments_) {
350
+ this.removeHeader('ETag');
351
+ this.removeHeader('X-Content-Type-Options');
352
+ return writeHead.apply(this, arguments_);
365
353
  };
366
- response.once('error',failed);
367
- response.once('finish',completed);
368
- response.once('close',completed);
369
- response.end(head?undefined:content);
370
- });
354
+ await fileServer.serveFile(opened.candidate, request, response);
355
+ return;
356
+ }
357
+ await serveGeneratedRepresentation(
358
+ fileServer,
359
+ request,
360
+ response,
361
+ {
362
+ lastModified,
363
+ contentType: MIME_TYPES.get(extension) || 'application/octet-stream',
364
+ body: async function createSourceRepresentation() {
365
+ let content = opened.content ?? await readFile(opened.candidate);
366
+ if (rewrite) {
367
+ content = rewriteAssetReferences(
368
+ content.toString('utf8'),
369
+ {filePath: opened.candidate, version: assetVersion, onReference}
370
+ );
371
+ }
372
+ if (pwaEntry) {
373
+ content = applyPwaEntryReferences(
374
+ content.toString('utf8'),
375
+ {manifestUrl: '/arcane.webmanifest', bootstrapUrl: '/arcane-pwa.mjs'}
376
+ );
377
+ }
378
+ return content;
379
+ }
380
+ }
381
+ );
382
+ }
383
+
384
+ function observeResponseCompletion(response) {
385
+ return new Promise(
386
+ function observeOwnedResponse(resolve, reject) {
387
+ function releaseResponseListeners() {
388
+ response.removeListener('error', responseFailed);
389
+ response.removeListener('finish', responseCompleted);
390
+ response.removeListener('close', responseCompleted);
391
+ }
392
+ function responseCompleted() {
393
+ releaseResponseListeners();
394
+ resolve();
395
+ }
396
+ function responseFailed(error) {
397
+ releaseResponseListeners();
398
+ reject(error);
399
+ }
400
+ response.once('error', responseFailed);
401
+ response.once('finish', responseCompleted);
402
+ response.once('close', responseCompleted);
403
+ }
404
+ );
371
405
  }
372
406
 
373
407
  function sourcePathAllowed(relative,manifest){
@@ -628,27 +662,71 @@ async function sourcePwaAssets(routeSet, mappings, signal, resourceUrls, resourc
628
662
  return [...selectedUrls].sort();
629
663
  }
630
664
 
631
- function listen(server,{host,port,signal}){
632
- return new Promise((resolve,reject)=>{
633
- const cleanup=()=>{
634
- signal?.removeEventListener('abort',abort);
635
- server.removeListener('error',failed);
636
- };
637
- const abort=()=>server.close(()=>{
638
- cleanup();
639
- reject(signal.reason||new Error('Operation cancelled.'));
640
- });
641
- const failed=error=>{
642
- cleanup();
643
- reject(error);
644
- };
645
- signal?.addEventListener('abort',abort,{once:true});
646
- server.once('error',failed);
647
- server.listen(port,host,()=>{
648
- cleanup();
649
- resolve();
650
- });
651
- });
665
+ function closeDevelopmentListeners(fileServer, tlsServer) {
666
+ const closeTls = tlsServer ? new Promise(
667
+ function closeRawTlsListener(resolve, reject) {
668
+ tlsServer.close(function rawTlsClosed(error) {
669
+ if (error && error.code !== 'ERR_SERVER_NOT_RUNNING') reject(error);
670
+ else resolve();
671
+ });
672
+ }
673
+ ) : Promise.resolve();
674
+ return Promise.allSettled([fileServer.close(), closeTls]).then(
675
+ function developmentListenersClosed(results) {
676
+ const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
677
+ if (errors.length === 1) throw errors[0];
678
+ if (errors.length > 1) throw new AggregateError(errors, 'Development listener shutdown failed.');
679
+ }
680
+ );
681
+ }
682
+
683
+ function deployDevelopmentServer(fileServer, signal, {tlsServer, host, port}) {
684
+ return new Promise(
685
+ function deployOwnedListeners(resolve, reject) {
686
+ let listeners = [];
687
+ const ready = new Set();
688
+ let settled = false;
689
+ function cleanupDeployment() {
690
+ signal?.removeEventListener('abort', abortDeployment);
691
+ for (const listener of listeners) listener.removeListener('error', failedDeployment);
692
+ }
693
+ async function failedDeployment(error) {
694
+ if (settled) return;
695
+ settled = true;
696
+ try {
697
+ await closeDevelopmentListeners(fileServer, tlsServer);
698
+ } catch (closeError) {
699
+ error = new AggregateError([error, closeError], 'Development listener startup failed.');
700
+ } finally {
701
+ cleanupDeployment();
702
+ }
703
+ reject(error);
704
+ }
705
+ function abortDeployment() {
706
+ void failedDeployment(signal.reason || new Error('Operation cancelled.'));
707
+ }
708
+ function listenerReady(instance, readyListener) {
709
+ if (settled) return;
710
+ ready.add(readyListener);
711
+ if (!listeners.every(listener => ready.has(listener))) return;
712
+ settled = true;
713
+ cleanupDeployment();
714
+ resolve(tlsServer ?? fileServer.secureServer);
715
+ }
716
+ try {
717
+ throwIfAborted(signal);
718
+ signal?.addEventListener('abort', abortDeployment, {once: true});
719
+ fileServer.deploy(listenerReady);
720
+ listeners = [fileServer.server, fileServer.secureServer, tlsServer].filter(Boolean);
721
+ for (const listener of listeners) listener.on('error', failedDeployment);
722
+ tlsServer?.listen(port, host, function rawTlsListenerReady() {
723
+ listenerReady(fileServer, tlsServer);
724
+ });
725
+ } catch (error) {
726
+ void failedDeployment(error);
727
+ }
728
+ }
729
+ );
652
730
  }
653
731
 
654
732
  function browserHostname(host){
@@ -674,14 +752,13 @@ function networkUrlsForAddress(address,startPath,protocol){
674
752
  return [...urls];
675
753
  }
676
754
 
677
- async function resolveDevelopmentTls({workspaceRoot,https:useHttps,tls,certPath,keyPath,signal}){
755
+ async function resolveDevelopmentTls({workspaceRoot,tls,certPath,keyPath,signal}){
678
756
  if(tls!==undefined&&tls!==null&&tls!==false){
679
757
  if(!is.object(tls)||is.array(tls)){
680
758
  fail('Development server tls must be a Node HTTPS options object.','ARCANE_USAGE');
681
759
  }
682
760
  return {options:tls};
683
761
  }
684
- if(certPath===undefined&&keyPath===undefined&&!useHttps)return undefined;
685
762
  if((certPath===undefined)!==(keyPath===undefined)){
686
763
  fail('HTTPS development requires both certPath and keyPath when either is supplied.','ARCANE_USAGE');
687
764
  }
@@ -689,21 +766,22 @@ async function resolveDevelopmentTls({workspaceRoot,https:useHttps,tls,certPath,
689
766
  const privateKeyPath=path.resolve(workspaceRoot,keyPath??'.arcane/dev/server-key.pem');
690
767
  try{
691
768
  const reads=await Promise.allSettled([
692
- readFile(certificatePath,{signal}),
693
- readFile(privateKeyPath,{signal})
769
+ realpath(certificatePath),
770
+ realpath(privateKeyPath)
694
771
  ]);
695
772
  for(const read of reads){
696
773
  if(read.status==='rejected')throw read.reason;
697
774
  }
698
775
  return {
699
- options:{cert:reads[0].value,key:reads[1].value},
700
- privateKeyPath:await realpath(privateKeyPath)
776
+ certificatePath: reads[0].value,
777
+ privateKeyPath: reads[1].value
701
778
  };
702
779
  }catch(error){
703
780
  if(error.code==='ENOENT'){
704
781
  fail(
705
- `HTTPS development requires a certificate at ${certificatePath} and a key at ${privateKeyPath}. `
706
- +'Provide that pair or select existing files with --cert and --key.',
782
+ `Arcane development and packaged previews require HTTPS. `
783
+ +`Provide a certificate at ${certificatePath} and a key at ${privateKeyPath}, `
784
+ +'or select existing files with --cert and --key.',
707
785
  'ARCANE_DEV_TLS_MISSING'
708
786
  );
709
787
  }
@@ -718,7 +796,7 @@ async function startOwnedDevServer({
718
796
  releaseRoot,
719
797
  host='127.0.0.1',
720
798
  port=0,
721
- https:useHttps=false,
799
+ httpPort=0,
722
800
  tls,
723
801
  certPath,
724
802
  keyPath,
@@ -734,6 +812,9 @@ async function startOwnedDevServer({
734
812
  fail('Development server host must be a nonempty string.','ARCANE_USAGE');
735
813
  }
736
814
  if(!is.integer(port)||port<0||port>65535)fail('port must be an integer from 0 through 65535.','ARCANE_USAGE');
815
+ if (!is.integer(httpPort) || httpPort < 0 || httpPort > 65535) {
816
+ fail('httpPort must be an integer from 0 through 65535.', 'ARCANE_USAGE');
817
+ }
737
818
  const requestedRuntimeMode=mode==='source'&&sdkRuntimeSourceRoot!==undefined
738
819
  ?'sdk-source'
739
820
  :null;
@@ -742,13 +823,14 @@ async function startOwnedDevServer({
742
823
  mode,
743
824
  host,
744
825
  port,
826
+ httpPort,
745
827
  appId,
746
828
  ...(requestedRuntimeMode?{runtimeMode:requestedRuntimeMode}:{})
747
829
  });
748
830
  const selectedTls=await resolveDevelopmentTls({
749
- workspaceRoot,https:useHttps,tls,certPath,keyPath,signal
831
+ workspaceRoot,tls,certPath,keyPath,signal
750
832
  });
751
- const protocol=selectedTls?'https:':'http:';
833
+ const protocol = 'https:';
752
834
  throwIfAborted(signal);
753
835
  const routeSet=mode==='source'
754
836
  ?await sourceRoutes(workspaceRoot,appId,{
@@ -759,6 +841,37 @@ async function startOwnedDevServer({
759
841
  :await packagedRoutes(releaseRoot);
760
842
  const mappings=deterministicMappings(routeSet.mappings);
761
843
  const pwaEnabled = mode === 'source' ? routeSet.app?.pwa?.enabled === true : routeSet.pwa;
844
+ const versionPath = mode === 'source'
845
+ ? sdkRuntimeSourceRoot === undefined
846
+ ? path.join(routeSet.workspaceRoot, 'arcane.lock.json')
847
+ : path.join(routeSet.runtime.sourceRoot, 'package.json')
848
+ : undefined;
849
+ const [generatorInputs, initialAssetVersion, versionInput] = await Promise.all(
850
+ [
851
+ mode === 'source' ? Promise.all(
852
+ [
853
+ lstat(new URL('./dev-server.mjs', import.meta.url)),
854
+ lstat(new URL('./import-map.mjs', import.meta.url)),
855
+ lstat(new URL('../package.json', import.meta.url))
856
+ ]
857
+ ) : [],
858
+ selectedAssetVersion(),
859
+ versionPath ? lstat(versionPath).catch(
860
+ function optionalVersionMetadata(error) {
861
+ if (error.code !== 'ENOENT') throw error;
862
+ return null;
863
+ }
864
+ ) : null
865
+ ]
866
+ );
867
+ const generatorModifiedAt = Math.max(
868
+ 0,
869
+ ...generatorInputs.map(
870
+ function generatorModification(input) {
871
+ return input.mtimeMs;
872
+ }
873
+ )
874
+ );
762
875
  async function selectedAssetVersion(){
763
876
  if(mode!=='source')return undefined;
764
877
  return sdkRuntimeSourceRoot===undefined
@@ -767,7 +880,49 @@ async function startOwnedDevServer({
767
880
  path.join(routeSet.runtime.sourceRoot,'package.json'),'utf8'
768
881
  )).version;
769
882
  }
770
- let assetVersion=await selectedAssetVersion();
883
+ let assetVersion = initialAssetVersion;
884
+ let assetVersionModifiedAt = Math.max(generatorModifiedAt, versionInput?.mtimeMs ?? 0);
885
+ function rememberAssetVersion(version) {
886
+ if (version !== assetVersion) assetVersionModifiedAt = Date.now();
887
+ assetVersion = version;
888
+ }
889
+ const documentMetadata = new Map();
890
+ const representationMetadata = new Map();
891
+ async function isManagedDocument(opened) {
892
+ const previous = documentMetadata.get(opened.candidate);
893
+ const modifiedAt = opened.modifiedAt.getTime();
894
+ if (previous?.modifiedAt === modifiedAt) return previous.managed;
895
+ opened.content = await readFile(opened.candidate);
896
+ let managed = false;
897
+ if (opened.content.includes(MANAGED_IMPORT_MAP_ATTRIBUTE)) {
898
+ try {
899
+ managed = inspectImportMapHtml(opened.content.toString('utf8')).managedMaps.length > 0;
900
+ } catch {
901
+ // An unrelated document is not a source validation surface.
902
+ }
903
+ }
904
+ documentMetadata.set(
905
+ opened.candidate,
906
+ {modifiedAt, managed}
907
+ );
908
+ return managed;
909
+ }
910
+ function sourceRepresentationModifiedAt(opened, version, pwaEntry) {
911
+ const modifiedAt = opened.modifiedAt.getTime();
912
+ const previous = representationMetadata.get(opened.candidate);
913
+ if (previous?.modifiedAt === modifiedAt
914
+ && previous.version === version && previous.pwaEntry === pwaEntry) {
915
+ return previous.lastModified;
916
+ }
917
+ const lastModified = new Date(
918
+ Math.max(modifiedAt, generatorModifiedAt, assetVersionModifiedAt, previous ? Date.now() : 0)
919
+ );
920
+ representationMetadata.set(
921
+ opened.candidate,
922
+ {modifiedAt, version, pwaEntry, lastModified}
923
+ );
924
+ return lastModified;
925
+ }
771
926
  for(const mapping of mappings){
772
927
  const info=await lstat(mapping.root);
773
928
  if(info.isSymbolicLink()||!info.isDirectory())fail(`Server route root must be a real directory: ${mapping.root}.`);
@@ -775,6 +930,7 @@ async function startOwnedDevServer({
775
930
  }
776
931
  let pwaArtifacts;
777
932
  let pwaInventoryTask;
933
+ const generatedMetadata = new Map();
778
934
  const pwaResourceUrls = new Set();
779
935
  function developmentPwaArtifacts(assets = []) {
780
936
  return createPwaArtifacts(
@@ -797,17 +953,30 @@ async function startOwnedDevServer({
797
953
  }
798
954
  );
799
955
  }
956
+ function rememberPwaArtifacts(artifacts) {
957
+ for (const file of artifacts.files) {
958
+ const previous = generatedMetadata.get(file.path);
959
+ const lastModified = previous?.content === file.content
960
+ ? previous.lastModified
961
+ : new Date();
962
+ generatedMetadata.set(
963
+ file.path,
964
+ {content: file.content, lastModified}
965
+ );
966
+ }
967
+ pwaArtifacts = artifacts;
968
+ return artifacts;
969
+ }
800
970
  async function sourcePwaArtifact(targetPath) {
801
971
  if (targetPath === '/arcane-sw.js'
802
- || (targetPath === '/arcane-offline.json' && !pwaArtifacts)) {
972
+ || targetPath === '/arcane-offline.json') {
803
973
  if (!pwaInventoryTask) {
804
974
  pwaInventoryTask = Promise.all(
805
975
  [sourcePwaAssets(routeSet, mappings, signal, pwaResourceUrls, resourcePaths), selectedAssetVersion()]
806
976
  ).then(
807
977
  function prepareSourceOfflineInventory([assets, version]) {
808
- assetVersion = version;
809
- pwaArtifacts = developmentPwaArtifacts(assets);
810
- return pwaArtifacts;
978
+ rememberAssetVersion(version);
979
+ return rememberPwaArtifacts(developmentPwaArtifacts(assets));
811
980
  }
812
981
  ).finally(
813
982
  function releaseSourceInventoryTask() {
@@ -817,21 +986,31 @@ async function startOwnedDevServer({
817
986
  }
818
987
  await pwaInventoryTask;
819
988
  }
820
- const artifacts = pwaArtifacts ?? developmentPwaArtifacts();
821
- return artifacts.files.find(
989
+ const artifacts = pwaArtifacts ?? rememberPwaArtifacts(developmentPwaArtifacts());
990
+ const file = artifacts.files.find(
822
991
  function requestedPwaFile(file) {
823
992
  return `/${file.path}` === targetPath;
824
993
  }
825
994
  );
995
+ return {...file, lastModified: generatedMetadata.get(file.path).lastModified};
826
996
  }
827
997
  // Remember actual resource edges as their owners are served, not every
828
998
  // HTML/JS/CSS file in an application's document or attachment corpus.
829
999
  const resourcePaths=new Set([routeSet.startPath]);
830
1000
  const requestTasks=new Set();
831
1001
  const runFileWork=createFileWorkLimiter();
832
- function serveDevelopmentRequest(request,response){
1002
+ async function serveDevelopmentRequest(request, response) {
833
1003
  let task;
834
- task=(async()=>{
1004
+ async function routeDevelopmentRequest() {
1005
+ if (!request.socket.encrypted) {
1006
+ const address = (tlsServer ?? fileServer.secureServer).address();
1007
+ const authority = new URL(`http://${request.headers.host || browserHostname(host)}`);
1008
+ authority.protocol = 'https:';
1009
+ authority.port = String(address.port);
1010
+ response.writeHead(308, {Location: `${authority.origin}${request.url || '/'}`});
1011
+ response.end();
1012
+ return;
1013
+ }
835
1014
  if(request.method!=='GET'&&request.method!=='HEAD'){
836
1015
  deny(response,405,'Method not allowed.');
837
1016
  return;
@@ -842,10 +1021,18 @@ async function startOwnedDevServer({
842
1021
  if (mode === 'source' && pwaEnabled
843
1022
  && ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs'].includes(target.path)) {
844
1023
  const generated = await sourcePwaArtifact(target.path);
845
- await sendFile(
1024
+ response.setHeader('Cache-Control', 'no-cache');
1025
+ await serveGeneratedRepresentation(
1026
+ fileServer,
1027
+ request,
846
1028
  response,
847
- {candidate: generated.path, content: Buffer.from(generated.content, 'utf8')},
848
- {head: request.method === 'HEAD', revalidate: true}
1029
+ {
1030
+ lastModified: generated.lastModified,
1031
+ contentType: MIME_TYPES.get(path.extname(generated.path)),
1032
+ body: function generatedPwaBody() {
1033
+ return generated.content;
1034
+ }
1035
+ }
849
1036
  );
850
1037
  return;
851
1038
  }
@@ -861,8 +1048,12 @@ async function startOwnedDevServer({
861
1048
  const relative=segments.slice(mapping.prefix.length);
862
1049
  if(relative.length===0){deny(response,404,'Not found.');return;}
863
1050
  if(mapping.allow&&!mapping.allow(relative)){deny(response,404,'Not found.');return;}
864
- await runFileWork(async()=>{
865
- const opened=await openSafeFile(mapping.root,relative);
1051
+ await runFileWork(async function serveMappedResource() {
1052
+ const opened = await openSafeFile(
1053
+ mapping.root,
1054
+ relative,
1055
+ {readContent: false}
1056
+ );
866
1057
  if(!opened){deny(response,404,'Not found.');return;}
867
1058
  if(selectedTls?.privateKeyPath
868
1059
  &&canonicalLocationKey(opened.candidate)===canonicalLocationKey(selectedTls.privateKeyPath)){
@@ -871,24 +1062,16 @@ async function startOwnedDevServer({
871
1062
  }
872
1063
  const extension=path.extname(opened.candidate).toLowerCase();
873
1064
  const html=extension==='.html'||extension==='.htm';
874
- let managedDocument=false;
875
- if(html&&opened.content.includes(MANAGED_IMPORT_MAP_ATTRIBUTE)){
876
- try{
877
- managedDocument=inspectImportMapHtml(
878
- opened.content.toString('utf8')
879
- ).managedMaps.length>0;
880
- }catch{
881
- // An unrelated document is not a source validation surface.
882
- }
883
- }
884
1065
  const selectedPwaDocument = mode === 'source' && pwaEnabled && html
885
1066
  && mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId
886
1067
  && routeSet.app.include.includes(relative.join('/'));
887
- const entryDocument=target.path===routeSet.startPath||managedDocument||selectedPwaDocument;
1068
+ const selectedDocument = target.path === routeSet.startPath || selectedPwaDocument;
1069
+ const managedDocument = !selectedDocument && html && await isManagedDocument(opened);
1070
+ const entryDocument = selectedDocument || managedDocument;
888
1071
  const managedMap=path.basename(opened.candidate)==='arcane.importmap.json';
889
1072
  // A live server can span an SDK upgrade. Refresh the small
890
1073
  // version record on navigation, not on each resource request.
891
- if(entryDocument||managedMap)assetVersion=await selectedAssetVersion();
1074
+ if(entryDocument||managedMap)rememberAssetVersion(await selectedAssetVersion());
892
1075
  const runtimeResource=segments[0]==='arcane';
893
1076
  const browserResource=['script','style','worker','sharedworker','serviceworker']
894
1077
  .includes(request.headers['sec-fetch-dest']);
@@ -920,33 +1103,85 @@ async function startOwnedDevServer({
920
1103
  }
921
1104
  }catch{ /* Non-URL values remain under their existing owner. */ }
922
1105
  };
923
- await sendFile(response,opened,{
924
- head:request.method==='HEAD',
925
- assetVersion:rewrite&&mode==='source'?(pwaEnabled?null:assetVersion):undefined,
926
- revalidate:pwaEnabled||entryDocument||managedMap,
927
- pwaEntry:mode==='source'&&pwaEnabled&&entryDocument,
928
- onReference
929
- });
1106
+ const selectedVersion = rewrite && mode === 'source' ? (pwaEnabled ? null : assetVersion) : undefined;
1107
+ const pwaEntry = mode === 'source' && pwaEnabled && entryDocument;
1108
+ const transformed = pwaEntry || (selectedVersion !== undefined
1109
+ && /\.(?:m?js|html?|css|json)$/iu.test(extension));
1110
+ await serveSourceFile(
1111
+ fileServer,
1112
+ request,
1113
+ response,
1114
+ opened,
1115
+ {
1116
+ assetVersion: selectedVersion,
1117
+ revalidate: pwaEnabled || entryDocument || managedMap,
1118
+ pwaEntry,
1119
+ onReference,
1120
+ lastModified: transformed
1121
+ ? sourceRepresentationModifiedAt(opened, selectedVersion, pwaEntry)
1122
+ : undefined
1123
+ }
1124
+ );
930
1125
  });
931
- })().catch(async error=>{
932
- await events.enqueue({type:'server.request.failed',message:error.message});
933
- if(!response.headersSent){
934
- const status=error?.code==='ARCANE_BACKPRESSURE'?503:500;
935
- deny(response,status,'Internal server error.');
1126
+ }
1127
+ const responseCompletion = observeResponseCompletion(response);
1128
+ task = Promise.all(
1129
+ [routeDevelopmentRequest(), responseCompletion]
1130
+ ).catch(
1131
+ async function failedDevelopmentRequest(error) {
1132
+ await events.enqueue({type: 'server.request.failed', message: error.message});
1133
+ if (!response.headersSent) {
1134
+ const status = error?.code === 'ARCANE_BACKPRESSURE' ? 503 : 500;
1135
+ deny(response, status, 'Internal server error.');
1136
+ } else if (!response.destroyed) {
1137
+ response.destroy(error);
1138
+ }
936
1139
  }
937
- else response.destroy(error);
938
- }).finally(()=>{
939
- requestTasks.delete(task);
940
- });
1140
+ ).finally(
1141
+ function releaseDevelopmentRequest() {
1142
+ requestTasks.delete(task);
1143
+ }
1144
+ );
941
1145
  requestTasks.add(task);
1146
+ await task;
1147
+ return true;
942
1148
  }
943
- const server=selectedTls
944
- ?https.createServer(selectedTls.options,serveDevelopmentRequest)
945
- :http.createServer(serveDevelopmentRequest);
946
- await listen(server,{host,port,signal});
1149
+ const contentType = Object.fromEntries(
1150
+ [...MIME_TYPES].map(
1151
+ function serverContentType([extension, value]) {
1152
+ return [extension.substring(1), value];
1153
+ }
1154
+ )
1155
+ );
1156
+ const fileServer = new Server(
1157
+ {
1158
+ root: mappings[0].root,
1159
+ host,
1160
+ port: httpPort,
1161
+ server: {noCache: false, timeout: 0},
1162
+ https: {
1163
+ only: false,
1164
+ port,
1165
+ ...(selectedTls.options ? {} : {
1166
+ privateKey: selectedTls.privateKeyPath,
1167
+ certificate: selectedTls.certificatePath
1168
+ })
1169
+ }
1170
+ }
1171
+ );
1172
+ fileServer.config.contentType = contentType;
1173
+ fileServer.onRawRequest = serveDevelopmentRequest;
1174
+ // The module's public HTTPS configuration accepts PEM paths. Its HTTPS
1175
+ // guide leaves advanced TLS inputs to application code; preserve that
1176
+ // existing SDK input while the same public module methods serve all content.
1177
+ const tlsServer = selectedTls.options
1178
+ ? https.createServer(selectedTls.options, serveDevelopmentRequest)
1179
+ : null;
1180
+ const server = await deployDevelopmentServer(fileServer, signal, {tlsServer, host, port});
1181
+ const listeners = [fileServer.server, server];
947
1182
  const address=server.address();
948
1183
  if(!address||is.string(address)){
949
- server.close();
1184
+ await closeDevelopmentListeners(fileServer, tlsServer);
950
1185
  fail('Development server did not expose a TCP address.');
951
1186
  }
952
1187
  const visibleHost=address.address==='0.0.0.0'||address.address==='::'
@@ -954,6 +1189,9 @@ async function startOwnedDevServer({
954
1189
  :browserHostname(address.address);
955
1190
  const endpoint=new URL(`${protocol}//${visibleHost}:${address.port}`);
956
1191
  const origin=endpoint.origin;
1192
+ const httpAddress = fileServer.server.address();
1193
+ const httpOrigin = new URL(`http://${visibleHost}:${httpAddress.port}`).origin;
1194
+ const httpUrl = `${httpOrigin}${routeSet.startPath}`;
957
1195
  const cleanUrl=`${origin}${routeSet.startPath}`;
958
1196
  const url=cleanUrl;
959
1197
  let closeInitiated=false;
@@ -975,7 +1213,7 @@ async function startOwnedDevServer({
975
1213
  }
976
1214
  lifecycleSettlementStarted=true;
977
1215
  signal?.removeEventListener('abort',abort);
978
- server.removeListener('error',serverFailed);
1216
+ for (const listener of listeners) listener.removeListener('error', serverFailed);
979
1217
  try{
980
1218
  while(requestTasks.size>0){
981
1219
  await Promise.allSettled([...requestTasks]);
@@ -1016,12 +1254,15 @@ async function startOwnedDevServer({
1016
1254
  if(!closeInitiated){
1017
1255
  closeInitiated=true;
1018
1256
  try{
1019
- server.close(closeError=>{
1020
- if(closeError){
1021
- operationalError??=closeError;
1257
+ closeDevelopmentListeners(fileServer, tlsServer).then(
1258
+ function developmentServerClosed() {
1259
+ void finishLifecycle();
1260
+ },
1261
+ function developmentServerCloseFailed(closeError) {
1262
+ operationalError ??= closeError;
1022
1263
  void finishLifecycle();
1023
1264
  }
1024
- });
1265
+ );
1025
1266
  }catch(closeError){
1026
1267
  operationalError??=closeError;
1027
1268
  void finishLifecycle();
@@ -1032,8 +1273,12 @@ async function startOwnedDevServer({
1032
1273
  const abort=()=>{void close().catch(()=>{});};
1033
1274
  const serverFailed=error=>{void close(error).catch(()=>{});};
1034
1275
  signal?.addEventListener('abort',abort,{once:true});
1035
- server.on('error',serverFailed);
1036
- server.once('close',()=>{void finishLifecycle();});
1276
+ for (const listener of listeners) {
1277
+ listener.on('error', serverFailed);
1278
+ listener.once('close', function ownedListenerClosed() {
1279
+ if (!closeInitiated) void close().catch(() => {});
1280
+ });
1281
+ }
1037
1282
  const result={
1038
1283
  server,
1039
1284
  protocol,
@@ -1046,6 +1291,9 @@ async function startOwnedDevServer({
1046
1291
  }:{}),
1047
1292
  host:address.address,
1048
1293
  port:address.port,
1294
+ httpPort: httpAddress.port,
1295
+ httpOrigin,
1296
+ httpUrl,
1049
1297
  origin,
1050
1298
  cleanUrl,
1051
1299
  url,
@@ -1062,6 +1310,9 @@ async function startOwnedDevServer({
1062
1310
  mode,
1063
1311
  host:result.host,
1064
1312
  port:result.port,
1313
+ httpPort: result.httpPort,
1314
+ httpOrigin,
1315
+ httpUrl,
1065
1316
  url,
1066
1317
  networkUrls:result.networkUrls,
1067
1318
  appId:result.appId,