arcane-os 0.11.2 → 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.
- package/CHANGELOG.md +25 -0
- package/NOTICE +1 -0
- package/README.md +24 -13
- package/browser-runtime/ai/browser-wllama-runtime.mjs +7 -0
- package/browser-runtime/pwa.mjs +129 -0
- package/docs/architecture.md +28 -12
- package/docs/reference/ai/browser-wasm.md +5 -2
- package/docs/reference/cli.md +42 -16
- package/docs/reference/inventory/package-api.json +1 -1
- package/docs/reference/pwa.md +87 -22
- package/docs/reference/sdk-api.md +61 -27
- package/package.json +2 -1
- package/src/app-descriptor.mjs +43 -1
- package/src/cli/main.mjs +29 -13
- package/src/dev-server.mjs +387 -136
- package/src/pwa-worker.mjs +362 -136
- package/src/pwa.mjs +4 -1
- package/src/targets/index.mjs +5 -1
- package/src/toolchain.mjs +62 -12
package/src/dev-server.mjs
CHANGED
|
@@ -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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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,
|
|
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
|
-
|
|
693
|
-
|
|
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
|
-
|
|
700
|
-
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
|
-
`
|
|
706
|
-
|
|
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
|
-
|
|
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,
|
|
831
|
+
workspaceRoot,tls,certPath,keyPath,signal
|
|
750
832
|
});
|
|
751
|
-
const protocol=
|
|
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=
|
|
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
|
-
||
|
|
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
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1024
|
+
response.setHeader('Cache-Control', 'no-cache');
|
|
1025
|
+
await serveGeneratedRepresentation(
|
|
1026
|
+
fileServer,
|
|
1027
|
+
request,
|
|
846
1028
|
response,
|
|
847
|
-
{
|
|
848
|
-
|
|
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(
|
|
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
|
|
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)
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
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,
|