arcane-os 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/docs/architecture.md +9 -0
- package/docs/reference/pwa.md +5 -2
- package/package.json +1 -1
- package/src/dev-server.mjs +146 -41
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.2
|
|
4
|
+
|
|
5
|
+
- Correct the PWA development guide to describe live descriptor refresh and
|
|
6
|
+
remove the superseded restart instruction. Runtime behavior is unchanged.
|
|
7
|
+
|
|
8
|
+
## 0.13.1
|
|
9
|
+
|
|
10
|
+
- Refresh live development app membership from the current authored descriptor
|
|
11
|
+
or package-only configuration. New explicit includes, exclusions, entry paths,
|
|
12
|
+
and PWA settings take effect on the next relevant request without a server
|
|
13
|
+
restart or writes to consumer projections.
|
|
14
|
+
- Generate PWA metadata and offline inventories from the same app snapshot.
|
|
15
|
+
Coalesce concurrent metadata reads and inventory work within that snapshot,
|
|
16
|
+
and keep older inventory completion from replacing newer metadata.
|
|
17
|
+
|
|
3
18
|
## 0.13.0
|
|
4
19
|
|
|
5
20
|
- Add the reusable `pwa-install.html` component and shared browser installation
|
package/docs/architecture.md
CHANGED
|
@@ -116,6 +116,15 @@ development-refresh lock, then releases the lock before binding the listener.
|
|
|
116
116
|
The authored descriptor remains unchanged, and package-only apps retain their
|
|
117
117
|
existing path. An enabled PWA receives generated manifests directly from this
|
|
118
118
|
source server without creating `dist` output.
|
|
119
|
+
While serving, the SDK rereads changed metadata for the selected app before
|
|
120
|
+
application, root-navigation, and generated-PWA requests. It projects the current
|
|
121
|
+
authored descriptor in memory, or reads the current package configuration for a
|
|
122
|
+
package-only app, without writing either file. Include/exclude rules, entry
|
|
123
|
+
selection, and PWA settings share one request snapshot. Overlapping reads reuse
|
|
124
|
+
one metadata task; unchanged files reuse their parsed selection. Shared runtime
|
|
125
|
+
requests do not wait for that refresh. Full offline inventories are enumerated
|
|
126
|
+
only for worker/offline-manifest requests, with separate state for each selected
|
|
127
|
+
app snapshot so an older inventory cannot overwrite newer generated metadata.
|
|
119
128
|
Changed resource requests return the complete current saved source without
|
|
120
129
|
packaging, copying files into `dist`, or restarting the server. Conditional
|
|
121
130
|
requests for unchanged resources return `304`. Enabled PWAs check on page load
|
package/docs/reference/pwa.md
CHANGED
|
@@ -102,8 +102,11 @@ metadata; `arcane-offline.json` contains the selected offline resource inventory
|
|
|
102
102
|
If `package.pwa.offline.include` is nonempty, the resource must also
|
|
103
103
|
match that offline selection and must not match `offline.exclude`. Adding a
|
|
104
104
|
path only to the offline selection does not add it to the app's resources.
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
The running source server reads changed app descriptors before the next app,
|
|
106
|
+
root-navigation or generated-PWA request. Include/exclude rules, the entry and
|
|
107
|
+
PWA settings share the current selection; no restart or package projection
|
|
108
|
+
write is needed. Edits to selected source files are picked up by the next due
|
|
109
|
+
page-load check while the server remains running.
|
|
107
110
|
|
|
108
111
|
For an enabled application, `arcane dev` serves the generated PWA files at the
|
|
109
112
|
origin root and starts at the selected app page under `/apps/<id>/`. Use one
|
package/package.json
CHANGED
package/src/dev-server.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import https from 'node:https';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {resolveWorkspace} from './workspace.mjs';
|
|
9
|
+
import {APP_DESCRIPTOR_NAME, projectPackageManifest} from './app-descriptor.mjs';
|
|
10
|
+
import {APP_CONFIG_NAME, validateAppConfig} from './packager/core.mjs';
|
|
9
11
|
import {createEventQueue} from './event-queue.mjs';
|
|
10
12
|
import {
|
|
11
13
|
applyPwaEntryReferences,inspectImportMapHtml,MANAGED_IMPORT_MAP_ATTRIBUTE,
|
|
@@ -324,13 +326,15 @@ async function openSafeFile(root, segments, {readContent = true} = {}) {
|
|
|
324
326
|
async function serveGeneratedRepresentation(fileServer, request, response, {
|
|
325
327
|
lastModified, contentType, body
|
|
326
328
|
}) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
329
|
+
if (lastModified) {
|
|
330
|
+
const modified = lastModified.toUTCString();
|
|
331
|
+
response.setHeader('Last-Modified', modified);
|
|
332
|
+
const requested = Date.parse(request.headers['if-modified-since']);
|
|
333
|
+
if (Number.isFinite(requested) && Date.parse(modified) <= requested) {
|
|
334
|
+
response.writeHead(304);
|
|
335
|
+
response.end();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
334
338
|
}
|
|
335
339
|
response.setHeader('Content-Type', contentType);
|
|
336
340
|
await fileServer.serve(request, response, await body());
|
|
@@ -459,6 +463,7 @@ async function sourceRoutes(workspaceRoot,appId,{
|
|
|
459
463
|
return {
|
|
460
464
|
workspaceRoot:resolved.workspaceRoot,
|
|
461
465
|
workspaceMode:resolved.config.workspaceMode,
|
|
466
|
+
config:resolved.config,
|
|
462
467
|
appId:resolved.appId,
|
|
463
468
|
app:resolved.app.manifest,
|
|
464
469
|
startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
|
|
@@ -470,6 +475,7 @@ async function sourceRoutes(workspaceRoot,appId,{
|
|
|
470
475
|
return {
|
|
471
476
|
workspaceRoot:resolved.workspaceRoot,
|
|
472
477
|
workspaceMode:'integrated',
|
|
478
|
+
config:resolved.config,
|
|
473
479
|
appId:resolved.appId,
|
|
474
480
|
app:resolved.app.manifest,
|
|
475
481
|
startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
|
|
@@ -488,6 +494,7 @@ async function sourceRoutes(workspaceRoot,appId,{
|
|
|
488
494
|
return {
|
|
489
495
|
workspaceRoot:resolved.workspaceRoot,
|
|
490
496
|
workspaceMode:'external',
|
|
497
|
+
config:resolved.config,
|
|
491
498
|
appId:resolved.appId,
|
|
492
499
|
app:resolved.app.manifest,
|
|
493
500
|
startPath:`/apps/${resolved.appId}/${resolved.app.manifest.entry}`,
|
|
@@ -840,7 +847,6 @@ async function startOwnedDevServer({
|
|
|
840
847
|
})
|
|
841
848
|
:await packagedRoutes(releaseRoot);
|
|
842
849
|
const mappings=deterministicMappings(routeSet.mappings);
|
|
843
|
-
const pwaEnabled = mode === 'source' ? routeSet.app?.pwa?.enabled === true : routeSet.pwa;
|
|
844
850
|
const versionPath = mode === 'source'
|
|
845
851
|
? sdkRuntimeSourceRoot === undefined
|
|
846
852
|
? path.join(routeSet.workspaceRoot, 'arcane.lock.json')
|
|
@@ -928,24 +934,89 @@ async function startOwnedDevServer({
|
|
|
928
934
|
if(info.isSymbolicLink()||!info.isDirectory())fail(`Server route root must be a real directory: ${mapping.root}.`);
|
|
929
935
|
mapping.root=await realpath(mapping.root);
|
|
930
936
|
}
|
|
931
|
-
let
|
|
932
|
-
let
|
|
933
|
-
|
|
937
|
+
let currentSourceRoutes = {...routeSet, mappings};
|
|
938
|
+
let sourceManifestInput;
|
|
939
|
+
let sourceManifestTask;
|
|
940
|
+
const appMapping = mappings.find(
|
|
941
|
+
function selectedApplicationMapping(mapping) {
|
|
942
|
+
return mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId;
|
|
943
|
+
}
|
|
944
|
+
);
|
|
945
|
+
async function refreshSourceRoutes() {
|
|
946
|
+
// Share concurrent metadata reads; unchanged descriptors need no parse
|
|
947
|
+
// or projection, and unrelated runtime requests do not wait here.
|
|
948
|
+
if (!sourceManifestTask) {
|
|
949
|
+
sourceManifestTask = readCurrentSourceRoutes().finally(
|
|
950
|
+
function releaseSourceManifestTask() {
|
|
951
|
+
sourceManifestTask = null;
|
|
952
|
+
}
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
return sourceManifestTask;
|
|
956
|
+
}
|
|
957
|
+
async function readCurrentSourceRoutes() {
|
|
958
|
+
throwIfAborted(signal);
|
|
959
|
+
let manifestPath = path.join(appMapping.root, APP_DESCRIPTOR_NAME);
|
|
960
|
+
let authored = true;
|
|
961
|
+
let input;
|
|
962
|
+
try {
|
|
963
|
+
input = await lstat(manifestPath);
|
|
964
|
+
} catch (error) {
|
|
965
|
+
if (error.code !== 'ENOENT') throw error;
|
|
966
|
+
authored = false;
|
|
967
|
+
manifestPath = path.join(appMapping.root, APP_CONFIG_NAME);
|
|
968
|
+
input = await lstat(manifestPath);
|
|
969
|
+
}
|
|
970
|
+
if (sourceManifestInput?.path === manifestPath
|
|
971
|
+
&& sourceManifestInput.modifiedAt === input.mtimeMs) {
|
|
972
|
+
return currentSourceRoutes;
|
|
973
|
+
}
|
|
974
|
+
const value = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
975
|
+
const manifest = validateAppConfig(
|
|
976
|
+
authored ? projectPackageManifest(value) : value,
|
|
977
|
+
routeSet.appId,
|
|
978
|
+
routeSet.config,
|
|
979
|
+
manifestPath
|
|
980
|
+
);
|
|
981
|
+
const currentAppMapping = {
|
|
982
|
+
...appMapping,
|
|
983
|
+
include: manifest.include,
|
|
984
|
+
allow: function currentSourcePathAllowed(relative) {
|
|
985
|
+
return sourcePathAllowed(relative, manifest);
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
// Each request retains its own selection while an inventory or file read
|
|
989
|
+
// runs. HTTP reads never rewrite the consumer's authored projections.
|
|
990
|
+
currentSourceRoutes = {
|
|
991
|
+
...routeSet,
|
|
992
|
+
app: manifest,
|
|
993
|
+
startPath: `/apps/${routeSet.appId}/${manifest.entry}`,
|
|
994
|
+
mappings: mappings.map(
|
|
995
|
+
function currentSourceMapping(mapping) {
|
|
996
|
+
return mapping === appMapping ? currentAppMapping : mapping;
|
|
997
|
+
}
|
|
998
|
+
)
|
|
999
|
+
};
|
|
1000
|
+
sourceManifestInput = {path: manifestPath, modifiedAt: input.mtimeMs};
|
|
1001
|
+
return currentSourceRoutes;
|
|
1002
|
+
}
|
|
1003
|
+
const pwaStates = new WeakMap();
|
|
1004
|
+
let currentPwaState;
|
|
934
1005
|
const pwaResourceUrls = new Set();
|
|
935
|
-
function developmentPwaArtifacts(assets = []) {
|
|
1006
|
+
function developmentPwaArtifacts(selectedRoutes, assets = [], version = assetVersion) {
|
|
936
1007
|
return createPwaArtifacts(
|
|
937
1008
|
{
|
|
938
1009
|
app: {
|
|
939
1010
|
id: routeSet.appId,
|
|
940
|
-
displayName:
|
|
941
|
-
version:
|
|
942
|
-
entry:
|
|
1011
|
+
displayName: selectedRoutes.app.displayName,
|
|
1012
|
+
version: selectedRoutes.app.version,
|
|
1013
|
+
entry: selectedRoutes.startPath
|
|
943
1014
|
},
|
|
944
|
-
sdkVersion:
|
|
945
|
-
pwa:
|
|
1015
|
+
sdkVersion: version,
|
|
1016
|
+
pwa: selectedRoutes.app.pwa,
|
|
946
1017
|
files: [],
|
|
947
1018
|
assets,
|
|
948
|
-
navigationAliases: {'/':
|
|
1019
|
+
navigationAliases: {'/': selectedRoutes.startPath},
|
|
949
1020
|
basePath: '/',
|
|
950
1021
|
appBase: `/apps/${routeSet.appId}/`,
|
|
951
1022
|
runtimeBase: '/arcane/sdk/',
|
|
@@ -953,46 +1024,66 @@ async function startOwnedDevServer({
|
|
|
953
1024
|
}
|
|
954
1025
|
);
|
|
955
1026
|
}
|
|
956
|
-
function rememberPwaArtifacts(artifacts) {
|
|
1027
|
+
function rememberPwaArtifacts(state, artifacts, generatedAt = new Date()) {
|
|
957
1028
|
for (const file of artifacts.files) {
|
|
958
|
-
const previous =
|
|
1029
|
+
const previous = state.metadata.get(file.path);
|
|
959
1030
|
const lastModified = previous?.content === file.content
|
|
960
1031
|
? previous.lastModified
|
|
961
|
-
:
|
|
962
|
-
|
|
1032
|
+
: generatedAt;
|
|
1033
|
+
state.metadata.set(
|
|
963
1034
|
file.path,
|
|
964
1035
|
{content: file.content, lastModified}
|
|
965
1036
|
);
|
|
966
1037
|
}
|
|
967
|
-
|
|
1038
|
+
state.artifacts = artifacts;
|
|
968
1039
|
return artifacts;
|
|
969
1040
|
}
|
|
970
|
-
async function sourcePwaArtifact(targetPath) {
|
|
1041
|
+
async function sourcePwaArtifact(targetPath, selectedRoutes) {
|
|
1042
|
+
let state = pwaStates.get(selectedRoutes);
|
|
1043
|
+
if (!state) {
|
|
1044
|
+
state = {metadata: new Map(currentPwaState?.metadata)};
|
|
1045
|
+
pwaStates.set(selectedRoutes, state);
|
|
1046
|
+
if (selectedRoutes === currentSourceRoutes) currentPwaState = state;
|
|
1047
|
+
}
|
|
971
1048
|
if (targetPath === '/arcane-sw.js'
|
|
972
1049
|
|| targetPath === '/arcane-offline.json') {
|
|
973
|
-
if (!
|
|
974
|
-
|
|
975
|
-
|
|
1050
|
+
if (!state.inventoryTask) {
|
|
1051
|
+
// Timestamp selection before traversal, so an older snapshot
|
|
1052
|
+
// finishing later cannot acquire a newer HTTP modification date.
|
|
1053
|
+
const generatedAt = new Date();
|
|
1054
|
+
state.inventoryTask = Promise.all(
|
|
1055
|
+
[
|
|
1056
|
+
sourcePwaAssets(selectedRoutes, selectedRoutes.mappings, signal, pwaResourceUrls, resourcePaths),
|
|
1057
|
+
selectedAssetVersion().then(
|
|
1058
|
+
function rememberCurrentInventoryVersion(version) {
|
|
1059
|
+
if (selectedRoutes === currentSourceRoutes) rememberAssetVersion(version);
|
|
1060
|
+
return version;
|
|
1061
|
+
}
|
|
1062
|
+
)
|
|
1063
|
+
]
|
|
976
1064
|
).then(
|
|
977
1065
|
function prepareSourceOfflineInventory([assets, version]) {
|
|
978
|
-
|
|
979
|
-
|
|
1066
|
+
return rememberPwaArtifacts(
|
|
1067
|
+
state,
|
|
1068
|
+
developmentPwaArtifacts(selectedRoutes, assets, version),
|
|
1069
|
+
generatedAt
|
|
1070
|
+
);
|
|
980
1071
|
}
|
|
981
1072
|
).finally(
|
|
982
1073
|
function releaseSourceInventoryTask() {
|
|
983
|
-
|
|
1074
|
+
state.inventoryTask = null;
|
|
984
1075
|
}
|
|
985
1076
|
);
|
|
986
1077
|
}
|
|
987
|
-
await
|
|
1078
|
+
await state.inventoryTask;
|
|
988
1079
|
}
|
|
989
|
-
const artifacts =
|
|
1080
|
+
const artifacts = state.artifacts ?? rememberPwaArtifacts(state, developmentPwaArtifacts(selectedRoutes));
|
|
990
1081
|
const file = artifacts.files.find(
|
|
991
1082
|
function requestedPwaFile(file) {
|
|
992
1083
|
return `/${file.path}` === targetPath;
|
|
993
1084
|
}
|
|
994
1085
|
);
|
|
995
|
-
return {...file, lastModified:
|
|
1086
|
+
return {...file, lastModified: state.metadata.get(file.path).lastModified};
|
|
996
1087
|
}
|
|
997
1088
|
// Remember actual resource edges as their owners are served, not every
|
|
998
1089
|
// HTML/JS/CSS file in an application's document or attachment corpus.
|
|
@@ -1018,16 +1109,24 @@ async function startOwnedDevServer({
|
|
|
1018
1109
|
const target=parseRequestTarget(request.url);
|
|
1019
1110
|
if(!target){deny(response,400,'Invalid request path.');return;}
|
|
1020
1111
|
const {segments}=target;
|
|
1112
|
+
const generatedPwaPath = ['/arcane.webmanifest', '/arcane-offline.json', '/arcane-sw.js', '/arcane-pwa.mjs']
|
|
1113
|
+
.includes(target.path);
|
|
1114
|
+
const appRequest = segments[0] === 'apps' && segments[1] === routeSet.appId;
|
|
1115
|
+
const selectedRoutes = mode === 'source' && (appRequest || segments.length === 0 || generatedPwaPath)
|
|
1116
|
+
? await refreshSourceRoutes() : currentSourceRoutes;
|
|
1117
|
+
const pwaEnabled = mode === 'source' ? selectedRoutes.app?.pwa?.enabled === true : routeSet.pwa;
|
|
1021
1118
|
if (mode === 'source' && pwaEnabled
|
|
1022
|
-
&&
|
|
1023
|
-
const generated = await sourcePwaArtifact(target.path);
|
|
1119
|
+
&& generatedPwaPath) {
|
|
1120
|
+
const generated = await sourcePwaArtifact(target.path, selectedRoutes);
|
|
1024
1121
|
response.setHeader('Cache-Control', 'no-cache');
|
|
1025
1122
|
await serveGeneratedRepresentation(
|
|
1026
1123
|
fileServer,
|
|
1027
1124
|
request,
|
|
1028
1125
|
response,
|
|
1029
1126
|
{
|
|
1030
|
-
|
|
1127
|
+
// An older in-flight snapshot may finish after a newer
|
|
1128
|
+
// selection. Its body cannot validate that newer output.
|
|
1129
|
+
lastModified: selectedRoutes === currentSourceRoutes ? generated.lastModified : undefined,
|
|
1031
1130
|
contentType: MIME_TYPES.get(path.extname(generated.path)),
|
|
1032
1131
|
body: function generatedPwaBody() {
|
|
1033
1132
|
return generated.content;
|
|
@@ -1037,12 +1136,18 @@ async function startOwnedDevServer({
|
|
|
1037
1136
|
return;
|
|
1038
1137
|
}
|
|
1039
1138
|
if(segments.length===0){
|
|
1040
|
-
response.writeHead(302,{location:
|
|
1139
|
+
response.writeHead(302,{location:selectedRoutes.startPath});
|
|
1041
1140
|
response.end();
|
|
1042
1141
|
return;
|
|
1043
1142
|
}
|
|
1044
|
-
const mapping=mappings.find(
|
|
1045
|
-
route
|
|
1143
|
+
const mapping = selectedRoutes.mappings.find(
|
|
1144
|
+
function requestedSourceMapping(route) {
|
|
1145
|
+
return route.prefix.every(
|
|
1146
|
+
function matchingSourcePrefix(segment, index) {
|
|
1147
|
+
return segments[index] === segment;
|
|
1148
|
+
}
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1046
1151
|
);
|
|
1047
1152
|
if(!mapping){deny(response,404,'Not found.');return;}
|
|
1048
1153
|
const relative=segments.slice(mapping.prefix.length);
|
|
@@ -1064,8 +1169,8 @@ async function startOwnedDevServer({
|
|
|
1064
1169
|
const html=extension==='.html'||extension==='.htm';
|
|
1065
1170
|
const selectedPwaDocument = mode === 'source' && pwaEnabled && html
|
|
1066
1171
|
&& mapping.prefix[0] === 'apps' && mapping.prefix[1] === routeSet.appId
|
|
1067
|
-
&&
|
|
1068
|
-
const selectedDocument = target.path ===
|
|
1172
|
+
&& selectedRoutes.app.include.includes(relative.join('/'));
|
|
1173
|
+
const selectedDocument = target.path === selectedRoutes.startPath || selectedPwaDocument;
|
|
1069
1174
|
const managedDocument = !selectedDocument && html && await isManagedDocument(opened);
|
|
1070
1175
|
const entryDocument = selectedDocument || managedDocument;
|
|
1071
1176
|
const managedMap=path.basename(opened.candidate)==='arcane.importmap.json';
|