arcane-os 0.28.4 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.29.1
4
+
5
+ - Preserve complete long and non-ASCII filenames in application release bundles
6
+ through standard per-file PAX path extensions. Public bundle creation and
7
+ verification retain logical payload paths and complete file content without
8
+ application-side renaming. Ordinary USTAR representation remains supported,
9
+ and the existing SDK-version metadata contract is unchanged. Bundles using
10
+ extended paths require the updated SDK reader or another PAX-capable reader.
11
+ - Preserve application-authored resource URL queries, including `v`, encoded
12
+ and repeated fields, empty query segments, and fragments. Import-map refresh,
13
+ serving, packaging, and PWA delivery now change only SDK-owned `arcaneVersion`
14
+ fields through the existing shared transformer.
15
+
16
+ ## 0.29.0
17
+
18
+ - Add `legacyAppPaths: false` to `arcane-packager.json` for root applications
19
+ using `appsRoot: "."`. Import-map refresh, source development, and packaging
20
+ omit SDK-generated `apps/<id>` navigation aliases and legacy PWA worker and
21
+ inventory files. Omission or `true` preserves the existing behavior.
22
+ - Preserve application storage identity, explicit PWA manifest identity, direct
23
+ installed npm resource URLs, and selected authored files. The SDK does not
24
+ delete existing historical files. Applications choosing root-only output own
25
+ removal of those files and the resulting loss of old bookmark, launch, and
26
+ service-worker update URLs that depended on them.
27
+
3
28
  ## 0.28.4
4
29
 
5
30
  - Preserve installed component-relative browser-runtime and strong-type import
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles preserve the same app URLs, theme, packaging,
20
20
  event, cancellation, and browser run contracts.
21
21
 
22
- This checkout defines the `0.28.4` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.29.1` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -604,9 +604,13 @@ prior file until the replacement is complete. Cancellation or failure before
604
604
  commit restores the prior output when that can be done without overwriting a
605
605
  concurrent change. A conflicting or uncertain path is preserved for inspection.
606
606
 
607
- The archive uses the documented USTAR+gzip structure and publishes its v1 JSON
608
- contract at `arcane-os/schemas/arcane-app-bundle.json`. When the user explicitly
609
- selects bundle verification, it rejects malformed archives, links, devices,
607
+ The archive uses USTAR+gzip with per-file PAX path extensions for long or
608
+ non-ASCII filenames and publishes its v1 JSON contract at
609
+ `arcane-os/schemas/arcane-app-bundle.json`. Logical filenames and content remain
610
+ complete; ordinary USTAR paths retain their existing representation, and bundle
611
+ metadata keeps its existing SDK-version matching contract. Readers must support
612
+ PAX path extensions to consume bundles that need them. When the
613
+ user explicitly selects bundle verification, it rejects malformed archives, links, devices,
610
614
  unsafe or colliding paths, unsupported archive members, trailing data, and
611
615
  inconsistent descriptor or inventory structure. These checks reject corrupt
612
616
  selected artifacts; they do not impose byte-count, hash, provenance, or
@@ -273,16 +273,31 @@ External repository delivery adds a distinct schema-1
273
273
  `arcane-app-release-bundle` envelope. Bundle creation uses an authored schema-2
274
274
  `arcane-app.json`; a synthesized package or registry projection remains valid for integrated
275
275
  packaging but is not used for an external bundle. The
276
- archive contains exactly `ARCANE_APP_BUNDLE.json`, canonical `arcane-app.json`,
276
+ logical file inventory contains exactly `ARCANE_APP_BUNDLE.json`, canonical `arcane-app.json`,
277
277
  `payload/ARCANE_APP_RELEASE.json`, and the release inventory beneath `payload/`
278
278
  in that order. The envelope adds no repository-only source or build tooling
279
279
  beyond that selected release inventory. Individual apps remain responsible for
280
280
  their authored source policy.
281
281
 
282
- The bundle contract uses the documented USTAR+gzip structure. Explicit bundle
283
- verification parses the selected archive without extraction and rejects
284
- genuinely malformed structures, unsafe or colliding paths, unsupported members,
285
- trailing data, and incompatible bundle generations. These corrupt-artifact
282
+ The bundle contract retains schema 1 and the `ustar+gzip` format identifier.
283
+ Ordinary representable ASCII paths use the existing USTAR headers. Long paths
284
+ and non-ASCII names use a [POSIX PAX](https://docs.oracle.com/cd/E86824_01/html/E54763/pax-1.html)
285
+ per-file `x` header containing the complete
286
+ UTF-8 `path`, immediately before that file's regular header and content. This
287
+ transport framing is not a payload member and never appears in the logical
288
+ inventory or `readFile()` results. Path spelling and file content remain unchanged;
289
+ the existing portable-path rules still apply. The public
290
+ `createCanonicalUstarHeader()` helper remains a single USTAR header and retains
291
+ its format-local field limits; complete bundles use `createAppReleaseBundle()`.
292
+
293
+ The bundle reader consumes these `path` extensions for the following file only,
294
+ as well as ordinary USTAR entries. Other PAX attributes and archive member
295
+ types remain outside this bundle profile. Older SDK readers without PAX support
296
+ cannot read bundles that require these extensions; update the consuming SDK
297
+ before importing one. The existing same-SDK-version bundle condition is unchanged.
298
+ Explicit bundle verification parses the selected archive without extraction and
299
+ rejects genuinely malformed structures, unsafe or colliding paths, unsupported
300
+ members, and incompatible bundle generations. These corrupt-artifact
286
301
  checks do not create byte-count, content-hash, provenance, or admission gates
287
302
  for ordinary development, packaging, serving, or running.
288
303
 
@@ -3,7 +3,7 @@
3
3
  Applications that enable [PWA delivery](pwa.md) use clean local resource URLs.
4
4
  Their generated offline manifest and service worker own the selected application
5
5
  and SDK release information. In that mode, the delivery transformer removes
6
- both `v` and `arcaneVersion`, preserving other query fields and fragments.
6
+ only the SDK-owned `arcaneVersion` field, preserving authored query fields and fragments.
7
7
  The behavior below continues to apply when PWA delivery is disabled and to native
8
8
  packages. Workspace runtime materialization remains usable by either target;
9
9
  the selected browser delivery applies its PWA URL policy.
@@ -15,11 +15,12 @@ SDK package metadata, not a timestamp, content measurement, or application
15
15
  constant.
16
16
 
17
17
  For example, an existing `./arcane/modules/HTMLImport.js?v=6#module` reference
18
- becomes `./arcane/modules/HTMLImport.js?arcaneVersion=${version}#module`.
19
- `arcaneVersion` is the sole resource version field: transformation removes `v`,
20
- updates the first existing `arcaneVersion`, and removes duplicate version fields.
21
- Regenerating for another SDK release replaces that version value. Unrelated
22
- query fields, their spelling, and fragments remain intact.
18
+ becomes `./arcane/modules/HTMLImport.js?v=6&arcaneVersion=${version}#module`.
19
+ `arcaneVersion` is the SDK's resource version field: transformation updates its
20
+ first existing value and removes duplicate `arcaneVersion` fields, or appends it
21
+ when absent. Regenerating for another SDK release replaces only that SDK value.
22
+ Authored fields, including `v`, encoded keys and values, repeated or empty query
23
+ segments, their source spelling, and fragments remain intact.
23
24
 
24
25
  ## Public tooling
25
26
 
@@ -228,6 +228,16 @@ portable runtime subpaths such as `arcane-os/preference-store` and
228
228
  modules. The result reports the complete map written to the selected
229
229
  application; no fixed entry count is a release contract.
230
230
 
231
+ For `appsRoot: "."`, the artifact is `modules/arcane.importmap.json` at the
232
+ application root. Set `"legacyAppPaths": false` in `arcane-packager.json` to
233
+ omit SDK-generated `apps/<id>/` navigation/PWA compatibility files and aliases.
234
+ The same workspace choice applies to `arcane dev` and `arcane package`; restart
235
+ an already running dev server after changing it. The default remains `true`.
236
+ Root PWA files, app/installation identity and selected authored files are
237
+ preserved. Existing files are never deleted by this option. See
238
+ [root-only generated output](pwa.md#root-only-generated-output) before changing
239
+ the URLs needed by previously installed apps.
240
+
231
241
  SDK `0.5.17` preserves the physical workspace route count and ordered include
232
242
  list. External and modern integrated routes require `components`, `css`,
233
243
  `dependencies`, `entities`, `img`, `modules`, and `sdk`; a physical workspace
@@ -532,6 +542,12 @@ selected SDK content directly from `node_modules`. Only the portable output
532
542
  receives copies; no workspace `arcane/` projection is required. Its runtime URLs,
533
543
  managed import-map targets, and PWA inventory destinations match source serving.
534
544
 
545
+ With `appsRoot: "."`, app files retain their root-relative layout. The optional
546
+ root-config `legacyAppPaths: false` omits SDK-generated compatibility files
547
+ beneath `apps/<id>/` from the planned and actual output. It does not omit
548
+ explicitly selected authored resources at those paths or change the default
549
+ packaged installation identity. Omission or `true` preserves existing behavior.
550
+
535
551
  ```text
536
552
  arcane package [--app <id>] [--dry-run]
537
553
  ```
@@ -464,14 +464,14 @@
464
464
  "name": "createAppReleaseBundle",
465
465
  "displayName": "createAppReleaseBundle()",
466
466
  "kind": "function",
467
- "signature": "async createAppReleaseBundle({ receipt, releaseRoot, outputPath, overwrite=false, signal, onEvent }={})",
467
+ "signature": "async createAppReleaseBundle({releaseRoot, appDescriptor, outputPath, overwrite=false, signal, onEvent}={})",
468
468
  "entrypoints": [
469
469
  "arcane-os",
470
470
  "arcane-os/release-bundle"
471
471
  ],
472
472
  "primaryImport": "arcane-os",
473
473
  "group": "Packaging and release bundles",
474
- "summary": "Writes one deterministic USTAR+gzip external application bundle from the selected authored release state.",
474
+ "summary": "Writes one deterministic USTAR+gzip external application bundle, using per-file POSIX PAX path extensions for complete long or non-ASCII filenames.",
475
475
  "availability": "Node",
476
476
  "protocol": "SDK packager and deterministic bundle contract",
477
477
  "normalization": "Normalized SDK validation with complete canonical archive and release content"
@@ -2164,7 +2164,7 @@
2164
2164
  ],
2165
2165
  "primaryImport": "arcane-os",
2166
2166
  "group": "Packaging and release bundles",
2167
- "summary": "Parses and authenticates one deterministic app bundle without extraction.",
2167
+ "summary": "Parses one deterministic app bundle without extraction, supporting ordinary USTAR entries and complete per-file POSIX PAX paths within the existing same-SDK-version contract.",
2168
2168
  "availability": "Node",
2169
2169
  "protocol": "SDK packager and deterministic bundle contract",
2170
2170
  "normalization": "Normalized SDK validation with complete canonical archive and release content"
@@ -180,6 +180,24 @@ query strings and fragments. It preserves authored files at those destinations.
180
180
  For a direct-installed root PWA it generates the static PWA records at the root,
181
181
  so normal static hosting needs no SDK request handler or runtime copy.
182
182
 
183
+ To select root-only generated output, set `"legacyAppPaths": false` beside
184
+ `"appsRoot": "."` in `arcane-packager.json`. This optional boolean defaults to
185
+ `true`; it has no effect on the `appsRoot: "apps"` layout. The shared import-map
186
+ refresh, source dev server, package inspection, dry run and package output then
187
+ omit SDK-generated `apps/<id>/` navigation pages, navigation aliases, and the
188
+ legacy PWA worker/offline inventory. Root PWA files and normal managed imports
189
+ remain available. Edit the root configuration before starting `arcane dev`;
190
+ restart an existing server after changing this workspace-level choice.
191
+
192
+ This option does not change the application ID, stored data, the existing
193
+ default installation ID or an authored `pwa.manifest.id`. It neither deletes
194
+ existing files nor removes authored resources from the app's include/exclude
195
+ selection. Explicitly included files under `apps/<id>/` still serve and package
196
+ as authored resources. Previously installed launch URLs and worker update URLs
197
+ need their old resources to remain available; retain the default compatibility
198
+ output when those URLs still need SDK support. See the
199
+ [root PWA compatibility boundary](pwa.md#root-only-generated-output).
200
+
183
201
  Direct-installed root maps expose `arcane-os/modules/<filename>` and
184
202
  `arcane-os/entities/<filename>` (including extensions), plus the existing
185
203
  focused lowercase package exports. They omit the old `arcane/*` and
@@ -71,6 +71,38 @@ introduced. Offline clients require a later successful connection to receive
71
71
  updated files; fixture coverage does not establish a particular installed app's
72
72
  actual browser lifecycle.
73
73
 
74
+ ### Root-only generated output
75
+
76
+ For a root application, add `"legacyAppPaths": false` to the workspace's
77
+ `arcane-packager.json`, alongside `"appsRoot": "."`. The default is `true`.
78
+ This selects root-only SDK-generated navigation and PWA output in import-map
79
+ refresh, `arcane dev`, package inspection/dry run, and browser packaging:
80
+
81
+ - The four root PWA files remain generated normally.
82
+ - The SDK generates no `apps/<id>/` navigation pages, legacy worker or legacy
83
+ offline inventory, and adds no legacy navigation aliases or dev redirects.
84
+ - The default source installation ID remains `/apps/<id>/`, the default
85
+ packaged installation ID remains `./`, and an explicit `manifest.id` remains
86
+ authoritative. An ID is an installation identifier, not a request to generate
87
+ a directory. App identity and saved application data remain unchanged.
88
+ - Existing files are left on disk. Explicitly selected authored old-path
89
+ resources remain in the normal source/package/offline inventory; this setting
90
+ is not a deletion or migration command.
91
+
92
+ Restart `arcane dev` after changing this workspace configuration. Omission or
93
+ `true` retains the compatibility behavior described above. The option has no
94
+ effect on apps whose configured `appsRoot` is `"apps"`.
95
+
96
+ An installed app may still launch an old `/apps/<id>/` URL, and an existing
97
+ worker registration may still update its old script/inventory URL. Disabling
98
+ generation does not redirect those installed clients, unregister their worker,
99
+ clear caches or guarantee their next update. Keep compatibility output enabled
100
+ while old URLs still require SDK support, or supply the required resources
101
+ through the application's own declared files and hosting policy. This SDK
102
+ option alone makes no claim about any existing installation's adoption.
103
+
104
+ ### Offline resource selection
105
+
74
106
  `offline.include` and `offline.exclude` select literal paths or directory
75
107
  prefixes from the selected emitted inventory. An omitted or empty include list
76
108
  selects that inventory; exclusions subtract from it. App files use app-relative
@@ -118,9 +150,10 @@ The same bootstrap starts one initially hidden `pwa-install.html` component with
118
150
  the generated manifest's app name. Component loading and worker registration
119
151
  proceed independently.
120
152
 
121
- The selected PWA browser delivery removes `v` and `arcaneVersion` from actual
122
- local resource references, including the managed import map. Other query fields,
123
- fragments, source spelling and unrelated payloads are preserved. The offline
153
+ The selected PWA browser delivery removes only SDK-owned `arcaneVersion` fields
154
+ from actual local resource references, including the managed import map. Authored
155
+ fields such as `v`, encoded and repeated fields, empty query segments, fragments,
156
+ source spelling and unrelated payloads are preserved. The offline
124
157
  manifest now carries release information. Non-PWA and native delivery retain
125
158
  the [existing asset version contract](asset-versioning.md).
126
159
 
@@ -629,12 +629,17 @@ async function previewNextPrerelease(workspaceRoot) {
629
629
 
630
630
  ### Overview
631
631
 
632
- Writes one deterministic USTAR+gzip external application bundle from the selected authored release state.
632
+ Writes one deterministic USTAR+gzip external application bundle from the selected
633
+ authored release state. Long or non-ASCII filenames use per-file POSIX PAX `path`
634
+ extensions, preserving their complete UTF-8 spelling and file content. Ordinary
635
+ USTAR entries remain unchanged. Metadata framing is excluded from the manifest's
636
+ logical file inventory. Existing portable-path rules, schema 1, and the
637
+ `ustar+gzip` format identifier remain unchanged.
633
638
 
634
639
  ### Signature and result
635
640
 
636
641
  ```text
637
- async createAppReleaseBundle({ receipt, releaseRoot, outputPath, overwrite=false, signal, onEvent }={})
642
+ async createAppReleaseBundle({releaseRoot, appDescriptor, outputPath, overwrite=false, signal, onEvent}={})
638
643
  ```
639
644
 
640
645
  Import it from `arcane-os` or `arcane-os/release-bundle`. The signature above states whether settlement is synchronous or promise-based. The overview and owning group define result authority, side effects, callbacks, events, cancellation, and lifecycle.
@@ -658,6 +663,9 @@ async function usecreateAppReleaseBundle(...arguments_) {
658
663
  ### Overview
659
664
 
660
665
  Builds the exact 512-byte canonical USTAR header for one validated bundle entry.
666
+ This low-level helper remains a single regular-file header with the USTAR field
667
+ limits. Use `createAppReleaseBundle()` to encode complete long or Unicode paths;
668
+ the bundle writer supplies PAX framing where needed.
661
669
 
662
670
  ### Signature and result
663
671
 
@@ -1041,6 +1049,15 @@ async function usevalidateAppConfig(...arguments_) {
1041
1049
 
1042
1050
  Validates one root packager mapping and its fixed shared-route boundaries.
1043
1051
 
1052
+ The schema-1 `arcane-packager.json` accepts the optional boolean
1053
+ `legacyAppPaths`, normalized to `true` when omitted. With `appsRoot: "."`,
1054
+ setting it to `false` omits generated legacy `apps/<id>/` navigation/PWA output
1055
+ across managed-map refresh, development serving, inspection and packaging.
1056
+ It leaves installation identity and authored file selection unchanged and has
1057
+ no effect with `appsRoot: "apps"`. See
1058
+ [standalone root applications](protocols.md#optional-standalone-root-application)
1059
+ for configuration, retained-file and existing-installation behavior.
1060
+
1044
1061
  ### Signature and result
1045
1062
 
1046
1063
  ```text
@@ -1095,7 +1112,14 @@ async function useverifyApp(...arguments_) {
1095
1112
 
1096
1113
  ### Overview
1097
1114
 
1098
- Parses and authenticates one deterministic app bundle without extraction.
1115
+ Parses one deterministic app bundle without extraction and checks its structural
1116
+ manifest and complete logical file inventory. It supports ordinary USTAR entries
1117
+ and per-file POSIX PAX `path` extensions. The complete extended path is used by
1118
+ `files` and `readFile()`; the metadata and regular-header placeholder are not
1119
+ payload files. Malformed or orphaned PAX records produce `ARCANE_BUNDLE_INVALID`.
1120
+ Other PAX attributes and archive member types remain unsupported. The existing
1121
+ same-SDK-version condition remains unchanged. An older reader without PAX support
1122
+ must be updated before importing a bundle that requires these path extensions.
1099
1123
 
1100
1124
  ### Signature and result
1101
1125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.28.4",
3
+ "version": "0.29.1",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://github.com/TheWizardNexus/arcane-os-sdk/schemas/arcane-app-bundle.schema.json",
4
4
  "title": "Arcane external application release bundle",
5
- "description": "Structural manifest for one complete USTAR+gzip application bundle.",
5
+ "description": "Structural manifest for one complete USTAR+gzip application bundle, with per-file POSIX PAX path extensions for long or non-ASCII filenames. PAX framing is excluded from the logical file inventory.",
6
6
  "type": "object",
7
7
  "required": [
8
8
  "schemaVersion",
@@ -5,6 +5,12 @@ export function appRelativeRoot(config, appId) {
5
5
  return config.appsRoot === '.' ? '' : `apps/${appId}`;
6
6
  }
7
7
 
8
+ // Generated compatibility URLs are separate from installation and storage identity.
9
+ export function rootAppLegacyPath(config, appId) {
10
+ return config.appsRoot === '.' && config.legacyAppPaths !== false
11
+ ? `apps/${appId}` : undefined;
12
+ }
13
+
8
14
  export function resolveAppRoot(workspaceRoot, config, appId) {
9
15
  return path.resolve(workspaceRoot, appRelativeRoot(config, appId));
10
16
  }
@@ -6,7 +6,7 @@ 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 {appRelativeRoot} from './app-layout.mjs';
9
+ import {appRelativeRoot,rootAppLegacyPath} from './app-layout.mjs';
10
10
  import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
11
11
  import {APP_DESCRIPTOR_NAME, projectPackageManifest} from './app-descriptor.mjs';
12
12
  import {APP_CONFIG_NAME, validateAppConfig} from './packager/core.mjs';
@@ -1047,10 +1047,11 @@ async function startOwnedDevServer({
1047
1047
  const pwaResourceUrls = new Set();
1048
1048
  function developmentPwaArtifacts(selectedRoutes, assets = [], version = assetVersion) {
1049
1049
  const rootApp = selectedRoutes.config.appsRoot === '.';
1050
+ const legacyAppPath = rootAppLegacyPath(selectedRoutes.config, selectedRoutes.appId);
1050
1051
  const appBase = applicationSourcePath(selectedRoutes.config,selectedRoutes.appId);
1051
1052
  const navigationAliases = {'/': selectedRoutes.startPath};
1052
- if (rootApp) {
1053
- const legacyBase = `/apps/${selectedRoutes.appId}`;
1053
+ if (legacyAppPath) {
1054
+ const legacyBase = `/${legacyAppPath}`;
1054
1055
  navigationAliases[legacyBase] = selectedRoutes.startPath;
1055
1056
  navigationAliases[`${legacyBase}/`] = selectedRoutes.startPath;
1056
1057
  for (const asset of [selectedRoutes.startPath,...assets]) {
@@ -1082,9 +1083,9 @@ async function startOwnedDevServer({
1082
1083
  basePath: '/',
1083
1084
  appBase,
1084
1085
  ...(rootApp ? {
1085
- installationId:`/apps/${routeSet.appId}/`,
1086
- legacyAppPath:`apps/${routeSet.appId}`
1086
+ installationId:`/apps/${routeSet.appId}/`
1087
1087
  } : {}),
1088
+ legacyAppPath,
1088
1089
  runtimeBase: selectedRoutes.browserRuntimeBase
1089
1090
  ?? selectedRoutes.installed?.browserRuntimeBase ?? '/arcane/sdk/',
1090
1091
  mode: 'development'
@@ -1176,7 +1177,7 @@ async function startOwnedDevServer({
1176
1177
  const target=parseRequestTarget(request.url);
1177
1178
  if(!target){deny(response,400,'Invalid request path.');return;}
1178
1179
  const {segments}=target;
1179
- const legacyAppRequest = mode === 'source' && routeSet.config.appsRoot === '.'
1180
+ const legacyAppRequest = mode === 'source' && rootAppLegacyPath(routeSet.config, routeSet.appId)
1180
1181
  && segments[0] === 'apps' && segments[1] === routeSet.appId;
1181
1182
  const legacyPwaRequest = legacyAppRequest && segments.length === 3
1182
1183
  && ['arcane-sw.js', 'arcane-offline.json'].includes(segments[2]);
@@ -847,7 +847,7 @@ function assetUrlVersionEdits(value,version){
847
847
  let decodedKey=key;
848
848
  try{decodedKey=decodeURIComponent(key.replaceAll('+',' '));}
849
849
  catch{decodedKey=key;}
850
- const remove=decodedKey==='v'||(decodedKey==='arcaneVersion'&&(clean||versionFound));
850
+ const remove=decodedKey==='arcaneVersion'&&(clean||versionFound);
851
851
  parameters.push({start:offset,end:offset+parameter.length,remove});
852
852
  if(decodedKey==='arcaneVersion'&&!versionFound&&!clean){
853
853
  versionFound=true;
@@ -859,10 +859,10 @@ function assetUrlVersionEdits(value,version){
859
859
  }
860
860
  offset+=parameter.length+1;
861
861
  }
862
- if(clean&&!parameters.some(function hasRemainingField(parameter){
863
- return !parameter.remove&&parameter.end>parameter.start;
862
+ if(clean&&parameters.every(function removesSdkField(parameter){
863
+ return parameter.remove;
864
864
  }))return [{start:queryStart,end:address.length,value:''}];
865
- // Remove adjacent obsolete fields together, including only their separator.
865
+ // Remove adjacent SDK version fields together, including only their separator.
866
866
  // Other field spelling and source-level escapes remain untouched.
867
867
  for(let index=0;index<parameters.length;index+=1){
868
868
  if(!parameters[index].remove)continue;
@@ -874,13 +874,10 @@ function assetUrlVersionEdits(value,version){
874
874
  value:''
875
875
  });
876
876
  }
877
- const lastRetained=parameters.findLast(function retainedParameter(parameter){
878
- return !parameter.remove;
879
- });
880
877
  if(!clean&&!versionFound)edits.push({
881
878
  start:address.length,
882
879
  end:address.length,
883
- value:`${lastRetained&&lastRetained.end>lastRetained.start?'&':''}arcaneVersion=${versionValue}`
880
+ value:`&arcaneVersion=${versionValue}`
884
881
  });
885
882
  return edits;
886
883
  }
@@ -12,7 +12,7 @@ import {
12
12
  } from 'node:fs/promises';
13
13
  import path from 'node:path';
14
14
  import {pathToFileURL} from 'node:url';
15
- import {appRelativeRoot,resolveAppRoot,rootAppNavigation} from '../app-layout.mjs';
15
+ import {appRelativeRoot,resolveAppRoot,rootAppLegacyPath,rootAppNavigation} from '../app-layout.mjs';
16
16
  import {readInstalledSdkLayout} from '../sdk-runtime-layout.mjs';
17
17
  import {withWorkspaceOperationLock} from '../workspace-operation-lock.mjs';
18
18
  import {
@@ -275,11 +275,14 @@ function validateSharedRoute(route,label){
275
275
  }
276
276
 
277
277
  export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
278
- assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads']),ROOT_CONFIG_NAME);
278
+ assertOnlyKeys(value,new Set(['schemaVersion','appsRoot','distRoot','sharedPayloads','legacyAppPaths']),ROOT_CONFIG_NAME);
279
279
  if(value.schemaVersion!==1)fail(`${ROOT_CONFIG_NAME}.schemaVersion must be 1.`);
280
280
  if(!['apps','.'].includes(value.appsRoot)||value.distRoot!=='dist'){
281
281
  fail(`${ROOT_CONFIG_NAME} must bind appsRoot to "apps" or "." and distRoot to "dist".`);
282
282
  }
283
+ if (value.legacyAppPaths !== undefined && !is.boolean(value.legacyAppPaths)) {
284
+ fail(`${ROOT_CONFIG_NAME}.legacyAppPaths must be a boolean.`);
285
+ }
283
286
  if(!isPlainObject(value.sharedPayloads)){
284
287
  fail(`${ROOT_CONFIG_NAME}.sharedPayloads must be an object.`);
285
288
  }
@@ -293,7 +296,10 @@ export function validateRootConfig(value,configPath=ROOT_CONFIG_NAME){
293
296
  validateSharedRoute(route,`sharedPayloads.${id}[${index}]`)
294
297
  );
295
298
  }
296
- return {schemaVersion:1,appsRoot:value.appsRoot,distRoot:'dist',sharedPayloads,configPath};
299
+ return {
300
+ schemaVersion:1,appsRoot:value.appsRoot,distRoot:'dist',sharedPayloads,configPath,
301
+ legacyAppPaths:value.legacyAppPaths ?? true
302
+ };
297
303
  }
298
304
 
299
305
  function normalizeOptionalRecord(value,label){
@@ -575,7 +581,7 @@ async function optionalDescriptor(context){
575
581
  async function inspectContext(context,{signal}={}){
576
582
  const records=await collectPackageRecords(context,{signal});
577
583
  const documents=await browserDocuments(records,context.config.entry);
578
- const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
584
+ const navigation=rootAppLegacyPath(context.rootConfig,context.appId)?rootAppNavigation(
579
585
  context.appId,context.config.entry,documents.map(document=>document.path)
580
586
  ):[];
581
587
  for(const redirect of navigation){
@@ -757,11 +763,11 @@ async function replaceDirectory(stagingRoot,outputRoot){
757
763
  async function packageWithContext(context,options={}){
758
764
  const {signal,onEvent,browserPwa=true}=options;
759
765
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
760
- const legacyAppPath=context.rootConfig.appsRoot==='.'?`apps/${context.appId}`:undefined;
766
+ const legacyAppPath=rootAppLegacyPath(context.rootConfig,context.appId);
761
767
  const appPath=appRelativeRoot(context.rootConfig,context.appId);
762
768
  const entryPath=appPackagePath(context,context.config.entry);
763
769
  const inspected=await inspectContext(context,{signal});
764
- const navigation=context.rootConfig.appsRoot==='.'?rootAppNavigation(
770
+ const navigation=legacyAppPath?rootAppNavigation(
765
771
  context.appId,context.config.entry,inspected.browserDocuments.map(document=>document.path)
766
772
  ):[];
767
773
  if(options.dryRun){
@@ -93,7 +93,7 @@ function splitUstarPath(archivePath){
93
93
  return {name,prefix};
94
94
  }
95
95
  }
96
- fail(`Archive path cannot be represented by ustar: ${normalized}.`);
96
+ return null;
97
97
  }
98
98
 
99
99
  function writeTextField(header,offset,length,value,label){
@@ -112,8 +112,10 @@ function writeOctalField(header,offset,length,value,label,{trailingSpace=false}=
112
112
  header.write(rendered,offset,length,'ascii');
113
113
  }
114
114
 
115
- export function createCanonicalUstarHeader(archivePath,size){
116
- const {name,prefix}=splitUstarPath(archivePath);
115
+ function createTarHeader(archivePath,size,type){
116
+ const fields=splitUstarPath(archivePath);
117
+ if(!fields)fail(`Archive path cannot be represented by ustar: ${archivePath}.`);
118
+ const {name,prefix}=fields;
117
119
  const header=Buffer.alloc(TAR_BLOCK_SIZE);
118
120
  writeTextField(header,0,100,name,'ustar name');
119
121
  writeOctalField(header,100,8,ARCHIVE_MODE,'ustar mode');
@@ -122,7 +124,7 @@ export function createCanonicalUstarHeader(archivePath,size){
122
124
  writeOctalField(header,124,12,size,'ustar size');
123
125
  writeOctalField(header,136,12,0,'ustar mtime');
124
126
  header.fill(0x20,148,156);
125
- header[156]=0x30;
127
+ header[156]=type;
126
128
  header.write('ustar\0',257,6,'ascii');
127
129
  header.write('00',263,2,'ascii');
128
130
  writeTextField(header,265,32,'root','ustar owner');
@@ -134,13 +136,45 @@ export function createCanonicalUstarHeader(archivePath,size){
134
136
  return header;
135
137
  }
136
138
 
137
- function tarEntry(archivePath,content){
138
- const header=createCanonicalUstarHeader(archivePath,content.length);
139
+ export function createCanonicalUstarHeader(archivePath,size){
140
+ return createTarHeader(archivePath,size,0x30);
141
+ }
142
+
143
+ function tarRecord(archivePath,content,type=0x30){
144
+ const header=createTarHeader(archivePath,content.length,type);
139
145
  const remainder=content.length%TAR_BLOCK_SIZE;
140
146
  const padding=remainder===0?Buffer.alloc(0):Buffer.alloc(TAR_BLOCK_SIZE-remainder);
141
147
  return Buffer.concat([header,content,padding]);
142
148
  }
143
149
 
150
+ function paxPathRecord(archivePath) {
151
+ const value = Buffer.from(` path=${archivePath}\n`, 'utf8');
152
+ // POSIX PAX framing includes the decimal prefix itself in the record length.
153
+ let length = value.length + 1;
154
+ while(String(length).length + value.length !== length) {
155
+ length = String(length).length + value.length;
156
+ }
157
+ const prefix = Buffer.from(String(length), 'ascii');
158
+ return Buffer.concat(
159
+ [prefix, value]
160
+ );
161
+ }
162
+
163
+ function tarEntry(archivePath, content) {
164
+ const fields = splitUstarPath(archivePath);
165
+ if(fields && !/[^\u0000-\u007f]/u.test(archivePath)) {
166
+ return tarRecord(archivePath, content);
167
+ }
168
+ // The following regular header is only a placeholder; PAX owns the complete path.
169
+ const extendedPath = paxPathRecord(archivePath);
170
+ return Buffer.concat(
171
+ [
172
+ tarRecord('PaxHeaders/entry', extendedPath, 0x78),
173
+ tarRecord('PaxPayload/entry', content)
174
+ ]
175
+ );
176
+ }
177
+
144
178
  async function realDirectory(location,label){
145
179
  const requested=path.resolve(location);
146
180
  let info;
@@ -308,9 +342,53 @@ function readOctalField(header,offset,length,label){
308
342
  return value;
309
343
  }
310
344
 
345
+ function readPaxPath(content) {
346
+ let offset = 0;
347
+ let archivePath;
348
+ while(offset < content.length) {
349
+ const separator = content.indexOf(0x20, offset);
350
+ if(separator < 0) {
351
+ fail('Bundle contains malformed PAX record framing.');
352
+ }
353
+ const lengthText = content.subarray(offset, separator).toString('latin1');
354
+ if(!/^[1-9][0-9]*$/u.test(lengthText)) {
355
+ fail('Bundle contains malformed PAX record framing.');
356
+ }
357
+ const length = Number(lengthText);
358
+ const end = offset + length;
359
+ if(!is.safeInteger(length) || end > content.length || end <= separator + 1
360
+ || content[end - 1] !== 0x0a) {
361
+ fail('Bundle contains an incomplete or malformed PAX record.');
362
+ }
363
+ const assignment = content.indexOf(0x3d, separator + 1);
364
+ if(assignment <= separator + 1 || assignment >= end - 1) {
365
+ fail('Bundle contains a malformed PAX assignment.');
366
+ }
367
+ const keyword = content.subarray(separator + 1, assignment).toString('utf8');
368
+ if(keyword !== 'path') {
369
+ fail(`Bundle contains an unsupported PAX field: ${keyword}.`);
370
+ }
371
+ try {
372
+ const decoder = new TextDecoder(
373
+ 'utf-8',
374
+ {fatal: true, ignoreBOM: true}
375
+ );
376
+ archivePath = decoder.decode(content.subarray(assignment + 1, end - 1));
377
+ } catch (error) {
378
+ fail(`Bundle PAX path is not valid UTF-8: ${error.message}.`);
379
+ }
380
+ offset = end;
381
+ }
382
+ if(archivePath === undefined) {
383
+ fail('Bundle PAX header is missing its path.');
384
+ }
385
+ return archivePath;
386
+ }
387
+
311
388
  function readTarEntries(archive){
312
389
  const entries=new Map();
313
390
  let offset=0;
391
+ let pendingPath;
314
392
  while(offset+TAR_BLOCK_SIZE<=archive.length){
315
393
  const header=archive.subarray(offset,offset+TAR_BLOCK_SIZE);
316
394
  if(header.every(value=>value===0))break;
@@ -321,21 +399,31 @@ function readTarEntries(archive){
321
399
  let actualChecksum=0;
322
400
  for(const value of checksumHeader)actualChecksum+=value;
323
401
  if(actualChecksum!==expectedChecksum)fail('Bundle contains a malformed ustar header.');
324
- if(header[156]!==0&&header[156]!==0x30)fail('Bundle contains a non-file archive entry.');
402
+ const type=header[156];
403
+ if(type!==0&&type!==0x30&&type!==0x78)fail('Bundle contains a non-file archive entry.');
325
404
  const name=readStringField(header,0,100);
326
405
  const prefix=readStringField(header,345,155);
327
- const archivePath=validateAppBundlePath(prefix?`${prefix}/${name}`:name,'archive path');
328
- if(entries.has(pathKey(archivePath)))fail(`Bundle contains a duplicate path: ${archivePath}.`);
406
+ const headerPath=prefix?`${prefix}/${name}`:name;
329
407
  const size=readOctalField(header,124,12,'ustar size');
330
408
  const contentStart=offset+TAR_BLOCK_SIZE;
331
409
  const contentEnd=contentStart+size;
332
- if(contentEnd>archive.length)fail(`Bundle entry is incomplete: ${archivePath}.`);
410
+ if(contentEnd>archive.length)fail(`Bundle entry is incomplete: ${pendingPath??headerPath}.`);
411
+ const content=archive.subarray(contentStart,contentEnd);
412
+ offset=contentStart+Math.ceil(size/TAR_BLOCK_SIZE)*TAR_BLOCK_SIZE;
413
+ if(type===0x78){
414
+ if(pendingPath!==undefined)fail('Bundle PAX path is missing its following file.');
415
+ pendingPath=readPaxPath(content);
416
+ continue;
417
+ }
418
+ const archivePath=validateAppBundlePath(pendingPath??headerPath,'archive path');
419
+ pendingPath=undefined;
420
+ if(entries.has(pathKey(archivePath)))fail(`Bundle contains a duplicate path: ${archivePath}.`);
333
421
  entries.set(pathKey(archivePath),{
334
422
  path:archivePath,
335
- content:Buffer.from(archive.subarray(contentStart,contentEnd))
423
+ content:Buffer.from(content)
336
424
  });
337
- offset=contentStart+Math.ceil(size/TAR_BLOCK_SIZE)*TAR_BLOCK_SIZE;
338
425
  }
426
+ if(pendingPath!==undefined)fail('Bundle PAX path is missing its following file.');
339
427
  return entries;
340
428
  }
341
429
 
package/src/toolchain.mjs CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  import {loadArcaneIntegratedProvider} from './integrated-provider-loader.mjs';
13
13
  import {startDevServer} from './dev-server.mjs';
14
14
  import {applyPwaEntryReferences,generateImportMap,readApplicationTestImportMapContext} from './import-map.mjs';
15
- import {rootAppNavigation} from './app-layout.mjs';
15
+ import {rootAppLegacyPath,rootAppNavigation} from './app-layout.mjs';
16
16
  import {createPwaArtifacts} from './pwa.mjs';
17
17
  import {readInstalledSdkLayout} from './sdk-runtime-layout.mjs';
18
18
  import {withWorkspaceOperationLock} from './workspace-operation-lock.mjs';
@@ -327,7 +327,16 @@ async function refreshPreparedImportMap(prepared,{signal,onEvent,workspaceOperat
327
327
  async function refreshRootApplicationFiles(prepared,inspected,importMap,{signal,onEvent}){
328
328
  const {workspaceRoot,appId}=prepared;
329
329
  const manifest=prepared.validation.app.manifest;
330
- const navigation=rootAppNavigation(appId,manifest.entry,inspected.browserDocuments.map(document=>document.path));
330
+ const legacyAppPath = rootAppLegacyPath(prepared.validation.config, appId);
331
+ const navigation = legacyAppPath ? rootAppNavigation(
332
+ appId,
333
+ manifest.entry,
334
+ inspected.browserDocuments.map(
335
+ function rootBrowserDocumentPath(document) {
336
+ return document.path;
337
+ }
338
+ )
339
+ ) : [];
331
340
  // These aliases belong to the SDK only after generation; retained app files stay authored.
332
341
  for(const redirect of navigation){
333
342
  throwIfAborted(signal);
@@ -343,9 +352,17 @@ async function refreshRootApplicationFiles(prepared,inspected,importMap,{signal,
343
352
  const entry=`/${manifest.entry.split('/').map(encodeURIComponent).join('/')}`;
344
353
  const navigationAliases={
345
354
  '/':entry,
346
- [`/apps/${appId}`]:entry,
347
- [`/apps/${appId}/`]:entry,
348
- ...Object.fromEntries(navigation.map(redirect=>[`/${redirect.path}`,redirect.target]))
355
+ ...(legacyAppPath ? {
356
+ [`/${legacyAppPath}`]:entry,
357
+ [`/${legacyAppPath}/`]:entry,
358
+ ...Object.fromEntries(
359
+ navigation.map(
360
+ function rootNavigationAlias(redirect) {
361
+ return [`/${redirect.path}`,redirect.target];
362
+ }
363
+ )
364
+ )
365
+ } : {})
349
366
  };
350
367
  // The source host serves the installed files in place. There is no runtime projection.
351
368
  const files=[...new Set([
@@ -361,7 +378,7 @@ async function refreshRootApplicationFiles(prepared,inspected,importMap,{signal,
361
378
  basePath:'/',
362
379
  appBase:'/',
363
380
  installationId:`/apps/${appId}/`,
364
- legacyAppPath:`apps/${appId}`,
381
+ legacyAppPath,
365
382
  runtimeBase:installed.browserRuntimeBase,
366
383
  mode:'development',
367
384
  navigationAliases