arcane-os 0.19.0 → 0.21.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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0
4
+
5
+ - Serve mail over HTTPS with HTTP/2 through the published `node-http-server`
6
+ PEM API, with HTTP/1.1 fallback on the same selected port, default 8025.
7
+ Preserve the friendly occupied-port failure and owned listener lifecycle.
8
+ - Read `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH` with the provider profile
9
+ from `.env.json` in one startup read. Resolve relative PEM paths from that
10
+ file's directory and report missing TLS settings before binding. Keep file
11
+ and TLS handling portable across Windows, Linux and macOS.
12
+ - Use native HTTP/2 headers and authority for the mail and CORS contracts,
13
+ preserving exact report content, subscription verification, provider
14
+ outcomes, existing credential injection and cancellation.
15
+ - Extend the mail reference and purpose-gate report with TLS ownership and
16
+ configuration; add focused protocol and configuration test source.
17
+
18
+ ## 0.20.0
19
+
20
+ - Restore repository-shaped application packages: selected app files remain
21
+ under `apps/<id>/`, alongside shared `arcane/` and configured runtime routes.
22
+ Authored document bases, import maps, module-relative imports and application
23
+ resource paths retain their development layout without rewriting product
24
+ documents or application code to relocate them.
25
+ - Restore deployment-relative `ARCANE_APP_RELEASE.app.start` and a root
26
+ `index.html` launcher while preserving the app-relative `app.entry` identity.
27
+ Package consumers use `app.start` for navigation. SDK packaged previews honor
28
+ that field and retain support for earlier flat packages through `app.entry`.
29
+ - Keep generated PWA files at the deployment root, resolve manifest metadata
30
+ against the packaged app directory, and preserve app-relative offline
31
+ selections alongside shared runtime paths. Source import-map inspection keeps
32
+ app-relative document paths and separately reports emitted package paths.
33
+ - Align packaging references with the current public return values and explicit
34
+ source import-map generation. Add package-layout and nested-deployment test
35
+ source while retaining complete authored-content coverage.
36
+
3
37
  ## 0.19.0
4
38
 
5
39
  - Read mail credentials from `.env.json` in the invocation directory through
package/README.md CHANGED
@@ -19,12 +19,13 @@ 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.19.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.21.0` 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
 
26
- The [mail gateway](docs/reference/mail.md) serves the configured host and defaults
27
- browser mail to `/v1/mail` on the current domain. Multiple applications can share
26
+ The [mail gateway](docs/reference/mail.md) serves HTTPS with HTTP/2 on port 8025,
27
+ using certificate paths from `.env.json`, and defaults browser mail to
28
+ `/v1/mail` on the current domain. Multiple applications can share
28
29
  one server with explicit allowed origins. Subscription verification is disabled
29
30
  until a `verifySubscription` callback is configured; that callback receives the
30
31
  application name and bearer subscription key before each provider attempt.
@@ -199,9 +199,27 @@ URLs and release schema:
199
199
  apps/<id>/arcane-app.json
200
200
  apps/<id>/arcane-package.json
201
201
  apps/<id>/index.html
202
+ dist/<id>/index.html
203
+ dist/<id>/apps/<id>/index.html
202
204
  dist/<id>/ARCANE_APP_RELEASE.json
203
205
  ```
204
206
 
207
+ Packaging places each selected app file beneath `apps/<id>/` in the release
208
+ and preserves every shared route destination. When no selected shared payload
209
+ supplies a root `index.html`, the SDK generates one that opens the selected app
210
+ entry. Authored document bases, app URLs, relative module imports, and shared
211
+ runtime paths therefore resolve through the same layout in
212
+ source development and packaged delivery. Packaging consumes the saved source
213
+ and managed import maps; use `arcane import-map` to refresh those maps, or the
214
+ ordinary `arcane dev` startup that performs that refresh.
215
+
216
+ The release manifest keeps `app.entry` relative to the app directory and records
217
+ the package launch URL in `app.start`, such as
218
+ `./apps/hello-world/index.html`. Its complete `files` inventory includes the
219
+ root `index.html` and the prefixed app files. Packaged browser serving opens
220
+ `app.start`; a previously generated flat release without `app.start` retains
221
+ its `app.entry` launch path.
222
+
205
223
  The authored schema-2 descriptor is canonical for new apps and projects an
206
224
  exact schema-1 `arcane-package.json` for current consumers. Existing Arcane
207
225
  apps synthesize that descriptor from their schema-1 package plus the current
@@ -48,7 +48,7 @@ meaning and cardinality rules:
48
48
  | `--workspace` | directory | Commands that select an external or integrated workspace; defaults to `.`. |
49
49
  | `--app` | app id or label | Workspace/app operations except shared scope and `verify-bundle`; optional diagnostic label for `mail serve`. |
50
50
  | `--arcane-root` | directory | `doctor`, native `build`/`run`, `native-doctor`, `native-prepare` |
51
- | `--host` / `--port` | host / integer 0–65535 | Browser `dev`/`run` default to HTTPS at `127.0.0.1:8000`; `mail serve` defaults to HTTP at `0.0.0.0:8025` and accepts an explicit bind host. |
51
+ | `--host` / `--port` | host / integer 0–65535 | Browser `dev`/`run` default to HTTPS at `127.0.0.1:8000`; `mail serve` defaults to HTTPS with HTTP/2 at `0.0.0.0:8025` and accepts an explicit bind host. |
52
52
  | `--http-port` | integer 0–65535 | Browser `dev`/`run` HTTP redirect listener; defaults to `0`, which selects an available port. |
53
53
  | `--public` | flag | `dev`; binds to `0.0.0.0` unless `--host` explicitly selects another address. |
54
54
  | `--http` | flag | `dev` only; serves source and PWA routes on one HTTP listener selected by `--port`, without TLS. |
@@ -195,8 +195,8 @@ Wrong or duplicate explicit app identity fails. The renderer then requires one
195
195
  path-correct base for every selected document. Included HTML files with neither
196
196
  the identity marker nor an active base are component fragments: they remain
197
197
  package files and are not rewritten with a document-level import map.
198
- Packaging and development use the same discovery owner, so directly navigable
199
- source pages and packaged pages receive the same complete managed import-map JSON.
198
+ Packaging and development use the same discovery owner. Packaging consumes
199
+ the saved managed import maps in directly navigable source pages.
200
200
 
201
201
  ```text
202
202
  arcane import-map [--workspace <directory>] [--app <id>]
@@ -254,19 +254,12 @@ together. A post-commit observer failure preserves delivery with
254
254
  `eventDelivery.status === 'degraded'` and `ARCANE_EVENT_DELIVERY_FAILED`; it
255
255
  does not roll back complete application content.
256
256
 
257
- An external package also publishes `/ARCANE_RUNTIME_PROJECTION.json`. The JSON
258
- is `{schemaVersion:1,kind:'arcane-app-runtime-projection',sdkVersion,
259
- pathPrefix:'arcane/',files:[{path}]}` and lists the complete packaged runtime.
260
- The development server exposes the same public route from its workspace
261
- projection. The private `/ARCANE_APP_RELEASE.json` record is not served to
262
- application code. Malformed projection data fails
263
- `ARCANE_RUNTIME_PROJECTION_INVALID`.
264
-
265
257
  `new` and `init` generate the map during scaffolding. `dev` refreshes all
266
- selected documents once before binding; non-dry-run `package` refreshes them
267
- once, then collects the complete release. Packaging does not run tests or
268
- checks automatically. Browser `build` and paired native packaging reuse the
269
- package flow. Explicit `test` and `check` operations read the existing map without regenerating it;
258
+ selected documents once before binding. `package` consumes the saved source
259
+ and maps; use `import-map` to refresh them explicitly before selecting output
260
+ that needs updated maps. Packaging does not run tests or checks automatically.
261
+ Browser `build` and paired native packaging reuse the package flow.
262
+ Explicit `test` and `check` operations read the existing map without regenerating it;
270
263
  `verify`, `bundle`, and browser `run` do not regenerate it. There is no
271
264
  watcher, polling, scheduled refresh, download, or self-update behavior.
272
265
 
@@ -504,9 +497,12 @@ npm exec -- arcane check --app hello-world
504
497
  ### Overview
505
498
 
506
499
  Creates one complete browser release beneath `dist/<id>/`, preserving the prior
507
- output until the replacement is complete. It refreshes the selected document
508
- map once, then assembles `dist`. Packaging does not run tests or checks
509
- automatically.
500
+ output until the replacement is complete. It consumes saved source and managed
501
+ import maps, places app files beneath `apps/<id>/`, and retains the configured
502
+ shared route destinations. When selected shared content supplies no root
503
+ `index.html`, the SDK generates one that opens the selected app entry.
504
+ Source document bases and resource URLs therefore retain their development
505
+ layout. Packaging does not run tests or checks automatically.
510
506
 
511
507
  ```text
512
508
  arcane package [--app <id>] [--dry-run]
@@ -515,6 +511,9 @@ arcane package [--app <id>] [--dry-run]
515
511
  ### Result
516
512
 
517
513
  The result includes the release root, manifest, and complete selected inventory.
514
+ `ARCANE_APP_RELEASE.json` keeps the authored app-relative `app.entry` and records
515
+ the package launch URL in `app.start`, such as `./apps/hello-world/index.html`.
516
+ The file inventory includes that app tree and the root `index.html`.
518
517
  `--dry-run` plans the package without refreshing source, running tests, or
519
518
  replacing output.
520
519
 
@@ -672,6 +671,8 @@ npm exec -- arcane build \
672
671
 
673
672
  For `--target browser`, starts the existing current `dist/<app>` release; it
674
673
  does not package, rebuild, test, check, or verify that release automatically.
674
+ It opens the release manifest's `app.start` URL. Older flat releases without
675
+ that field continue to open their `app.entry` path.
675
676
  The preview always uses HTTPS with the workspace certificate pair. Supply
676
677
  `--cert <file> --key <file>` together to use another pair; see
677
678
  [development HTTPS setup](#development-https-setup).
@@ -905,7 +906,7 @@ loss after the attempt begins is ambiguous because Resend may have accepted it.
905
906
 
906
907
  ### Mail gateway
907
908
 
908
- `mail serve` starts one owned Node HTTP gateway:
909
+ `mail serve` starts one owned HTTPS gateway with HTTP/2:
909
910
 
910
911
  ```text
911
912
  arcane mail serve [--profile <profile>] [--from <verified-sender>] [--app <label>] [--origin <exact-origin>] [--allow-to <addresses>] [--host 0.0.0.0] [--port 8025] [--request-timeout <ms>]
@@ -913,6 +914,30 @@ arcane mail serve [--profile <profile>] [--from <verified-sender>] [--app <label
913
914
 
914
915
  The selected `.env.json` profile supplies only the server-side Resend API key;
915
916
  omitting `--profile` selects `mail`.
917
+
918
+ Add the listener's certificate configuration at the top level of the same file:
919
+
920
+ ```json
921
+ {
922
+ "RESEND_API_KEY": "",
923
+ "MAIL_TLS_CERT_PATH": ".arcane/mail/fullchain.pem",
924
+ "MAIL_TLS_KEY_PATH": ".arcane/mail/private-key.pem"
925
+ }
926
+ ```
927
+
928
+ Supply an existing PEM certificate chain and its private key. Paths resolve
929
+ relative to `.env.json`, or may be absolute. They are shared across provider
930
+ profiles. Missing TLS settings name the fields to fill in before a listener
931
+ opens; the TLS owner reports PEM file errors. Keep private-key material outside
932
+ tracked source. The SDK repository already ignores `.arcane/` and `.env.json`.
933
+
934
+ The selected `node-http-server` module negotiates HTTP/2 with HTTP/1.1 fallback
935
+ on the same HTTPS port, default `8025`, with no plain-HTTP listener. Callers use
936
+ a hostname covered by the certificate, such as
937
+ `https://mail.example.com:8025/v1/mail`; `0.0.0.0` identifies the bind address.
938
+ Restart the gateway after replacing renewed certificate files. Certificate
939
+ issuance and renewal remain with the deployment's certificate owner.
940
+
916
941
  The CLI does not read a browser app key. Its optional `--app` value labels the
917
942
  server; the incoming request's `X-Mail-App` identifies the application for
918
943
  subscription verification. The HTTP authentication contract pairs that
@@ -273,11 +273,13 @@ committed acceptance result.
273
273
  ## Operate the CLI and gateway
274
274
 
275
275
  Create `.env.json` in the directory from which the mail command runs, then fill
276
- in the provider key:
276
+ in the provider key and the HTTPS certificate paths:
277
277
 
278
278
  ```json
279
279
  {
280
- "RESEND_API_KEY": ""
280
+ "RESEND_API_KEY": "",
281
+ "MAIL_TLS_CERT_PATH": "",
282
+ "MAIL_TLS_KEY_PATH": ""
281
283
  }
282
284
  ```
283
285
 
@@ -370,6 +372,34 @@ address and port. The server can serve callers from multiple domains on the
370
372
  same machine. Route the page's `/v1/mail` to this listener, or configure an
371
373
  explicit shared endpoint in the caller.
372
374
 
375
+ `mail serve` uses HTTPS with HTTP/2 on that selected port. The published
376
+ `node-http-server` PEM API owns TLS and negotiates HTTP/2 or HTTP/1.1 on the
377
+ same listener. It creates no additional plain-HTTP listener. The returned URL
378
+ uses `https://`; `0.0.0.0` is the bind address, so callers use the deployed
379
+ domain, for example `https://mail.example.com:8025/v1/mail`.
380
+
381
+ Set `MAIL_TLS_CERT_PATH` to the PEM certificate chain and `MAIL_TLS_KEY_PATH`
382
+ to its PEM private-key file. These top-level settings belong to the listener
383
+ and apply regardless of the selected provider profile. Relative paths resolve
384
+ from the directory containing `.env.json`; absolute paths are also accepted.
385
+ The certificate must cover the hostname callers use. One certificate may
386
+ cover multiple names; the gateway does not require one certificate per calling
387
+ application. Keep private-key files outside tracked source, such as in the
388
+ already-ignored `.arcane/` directory or an existing host certificate directory.
389
+
390
+ Startup reads the JSON configuration once, reports missing TLS settings before
391
+ binding, and lets the TLS owner report unreadable or unusable PEM files. It
392
+ does not generate certificates, modify system trust, or add a renewal watcher.
393
+ Restart the gateway after the configured certificate files are renewed.
394
+ The same Node file and TLS APIs are used on Windows, Linux, and macOS; Android
395
+ requires a compatible Node host and accessible configuration and certificate
396
+ paths. These platform contracts are separate from actual platform execution.
397
+
398
+ The public `createToolchain().mail({action: 'serve', ...options})` operation
399
+ uses the same JSON certificate pair. Internally, `startResendMailServer` accepts
400
+ `certPath` and `keyPath` and retains its existing HTTP behavior when neither
401
+ is supplied. That internal function is not an npm package export.
402
+
373
403
  If the selected port is occupied, startup reports
374
404
  `Mail port <port> is already taken, possibly by another mail server.`
375
405
  The CLI exits with status 1. Programmatic callers receive the same message,
@@ -383,7 +413,8 @@ preserve each report's sender or its provider template's default.
383
413
  `--origin` selects an exact allowed caller origin; the
384
414
  programmatic `origin` option also accepts an array for multiple origins. With
385
415
  no origins configured, the gateway accepts an Origin matching its request
386
- Host using HTTP or HTTPS. Requests without Origin continue normally.
416
+ authority (`:authority` for HTTP/2, `Host` for HTTP/1.1) using HTTP or HTTPS.
417
+ Requests without Origin continue normally.
387
418
  Cross-origin preflight permits `Content-Type`, `Idempotency-Key`, `X-Mail-App`,
388
419
  and `Authorization`. This is origin configuration, not a loopback policy.
389
420
 
@@ -423,7 +454,7 @@ POST request before any provider attempt; results are not cached.
423
454
 
424
455
  | Configured-verifier outcome | Gateway response |
425
456
  | --- | --- |
426
- | Missing, empty, or repeated `X-Mail-App` | `400 mail_invalid_headers` |
457
+ | Missing or empty `X-Mail-App` | `400 mail_invalid_headers` |
427
458
  | Missing or malformed Bearer credential | `401 mail_subscription_required` |
428
459
  | Callback returns anything except `true` | `401 mail_subscription_invalid` |
429
460
  | Callback throws | `503 mail_subscription_verification_failed`, retryable with the configured `retryableDelayMs` |
@@ -435,6 +466,11 @@ result. The SDK supplies no default verification URL or built-in Stripe
435
466
  endpoint adapter. Connecting the actual subscription endpoint is a separate
436
467
  hosting integration.
437
468
 
469
+ The gateway uses Node's native header representations. HTTP/1.1 uses
470
+ `headersDistinct`, retaining its repeated-field detection. HTTP/2 uses the
471
+ compatibility request's `headers` object; Node owns duplicate joining or
472
+ discarding for each field. The SDK does not split a native header value again.
473
+
438
474
  CLI startup output says `Subscription verification: disabled` or
439
475
  `Subscription verification: configured`. Structured `server.ready` output
440
476
  includes the corresponding `callerAuthentication` value.
@@ -39,13 +39,20 @@ display preference, routes and descriptions. Use real app icons; the SDK does
39
39
  not invent branding or claim that a browser has installed the app.
40
40
 
41
41
  Manifest URL fields are relative to the application directory. Source delivery
42
- and packaged delivery resolve those fields into their respective layouts.
43
- Absolute URL fields retain their authored destination.
42
+ and packaged delivery retain the app tree at `apps/<id>/`. For packaged output,
43
+ the default `start_url` is `./apps/<id>/<entry>`, resolved from the generated
44
+ root manifest. The default `id` and `scope` remain `./`, preserving the existing
45
+ deployment-root installation identity. Authored relative URL fields, including
46
+ explicit `id` and `scope` values, resolve against `./apps/<id>/`; an icon such
47
+ as `img/library.png` therefore remains beneath the app directory. Absolute URL
48
+ fields retain their authored destination.
44
49
 
45
50
  `offline.include` and `offline.exclude` select literal paths or directory
46
51
  prefixes from the selected emitted inventory. An omitted or empty include list
47
52
  selects that inventory; exclusions subtract from it. App files use app-relative
48
53
  paths and shared runtime files use paths such as `arcane/sdk/pwa.mjs`.
54
+ For example, selecting `modules` matches packaged
55
+ `apps/<id>/modules/...`; emitted offline URLs keep the `apps/<id>/` prefix.
49
56
  The application entry and generated PWA shell records are retained. Select the
50
57
  resources needed by every offline page, including its shared modules and styles.
51
58
  The worker script itself is never an application cache entry.
@@ -67,6 +74,13 @@ Browser packaging emits these files at the selected deployment root:
67
74
  | `arcane-sw.js` | Stable worker URL with the selected offline manifest embedded in its source. |
68
75
  | `arcane-pwa.mjs` | Independent registration and installation-component bootstrap importing the SDK client. |
69
76
 
77
+ The selected app files remain under `apps/<id>/`, with shared runtime routes
78
+ alongside them. The SDK supplies a root `index.html` launcher when selected
79
+ shared content has no root page. The generated PWA files stay at the deployment
80
+ root so their worker can serve the selected app and shared resources. The
81
+ worker is registered for the deployment root independently of any app-authored
82
+ manifest scope.
83
+
70
84
  Each packaged output gets one deployment revision shared by its offline
71
85
  manifest and worker. It distinguishes separately generated outputs even when
72
86
  their app and SDK versions match. It is not a content measurement.
@@ -719,6 +719,12 @@ matching `meta[name="arcane-app-id"]`; unmarked pages with an active `base`
719
719
  remain selected for patch compatibility. Included HTML with neither signal is
720
720
  retained as a package fragment rather than rewritten as a document.
721
721
 
722
+ `entry` and each app browser document's `path` stay relative to the app
723
+ directory. Each browser document also reports `packagePath`, the emitted path
724
+ such as `apps/hello-world/index.html`. The complete `files` inventory uses
725
+ package-relative paths and includes the root `index.html`. Shared
726
+ files retain their configured route destinations.
727
+
722
728
  ### Availability and normalization
723
729
 
724
730
  **Node.** Normalized SDK validation with complete canonical archive and release content. Deep protocol: [SDK packager and deterministic bundle contract](protocols.md).
@@ -772,27 +778,22 @@ when explicitly requested or when required for this selected release output.
772
778
  async packageApp(options)
773
779
  ```
774
780
 
775
- Import it from `arcane-os` or `arcane-os/packager`. Packaging refreshes the
776
- managed map once and preserves the complete selected source and browser
777
- document inventory. It rejects malformed configuration, descriptors,
778
- and the malformed selected release archive while preserving the previously
779
- selected output on failure. Each selected browser document receives the same
780
- deterministic map. The package root also contains the public
781
- `ARCANE_RUNTIME_PROJECTION.json` inventory:
782
-
783
- ```javascript
784
- {
785
- schemaVersion: 1,
786
- kind: 'arcane-app-runtime-projection',
787
- sdkVersion: '0.5.18',
788
- pathPrefix: 'arcane/',
789
- files: [{path}]
790
- }
791
- ```
781
+ Import it from `arcane-os` or `arcane-os/packager`. Packaging consumes the saved
782
+ source and managed import maps. Refresh maps through `arcane import-map` or
783
+ ordinary `arcane dev` startup before selecting output that needs updated maps.
784
+ The package preserves the complete selected content, applying the documented
785
+ asset-version and enabled browser-PWA transformations to resource references.
786
+ Malformed configuration or descriptors fail while preserving the prior output.
792
787
 
793
- The projection is an inventory, not an ordinary execution gate. Malformed or
794
- internally inconsistent selected projection data rejects with
795
- `ARCANE_RUNTIME_PROJECTION_INVALID`.
788
+ Selected app files are emitted beneath `apps/<id>/`; shared files retain their
789
+ configured route destinations. When selected shared content supplies no root
790
+ `index.html`, the SDK generates one that opens the selected app page. The result
791
+ contains `outputRoot`, `manifest`, and the complete `files` inventory.
792
+ `manifest.app.entry` stays app-relative, while `manifest.app.start`
793
+ is the package launch URL, such as `./apps/hello-world/index.html`. Release
794
+ schema `1`, kind `arcane-app-release`, and packager identity
795
+ `arcane-app-packager-v1` remain unchanged. A dry run returns the planned output
796
+ and file inventory without writing source or output.
796
797
 
797
798
  ### Availability and normalization
798
799
 
@@ -808,7 +809,7 @@ const packaged = await packageApp({
808
809
  appId: 'hello-world'
809
810
  });
810
811
 
811
- console.log(packaged.importMap.documentPaths);
812
+ console.log(packaged.manifest.app.start, packaged.files);
812
813
  ```
813
814
 
814
815
  ## PACKAGER_VERSION
@@ -4038,17 +4039,19 @@ console.log(result.importMap.documentPaths, result.importMap.documentCount);
4038
4039
  ### Overview
4039
4040
 
4040
4041
  Runs the high-level package operation for one selected application. It reads
4041
- the installed SDK/runtime selection, injects one deterministic
4042
- managed import map into every directly navigable included `.html`/`.htm`
4043
- browser document while preserving component fragments as package files, and
4044
- then packages the complete selected content without automatically running tests
4045
- or checks. Verification occurs only when explicitly requested or when required
4046
- for the selected release output. A failure leaves the previously accepted
4047
- distribution untouched. Success returns the low-level package result and
4048
- complete import-map document inventory. External
4049
- packages publish `ARCANE_RUNTIME_PROJECTION.json`; private
4050
- `ARCANE_APP_RELEASE.json` remains an internal verification authority rather
4051
- than an application route.
4042
+ the installed SDK/runtime selection and packages the saved source and managed
4043
+ import maps through `packageApp()`. Map generation remains the explicit
4044
+ `arcane import-map` operation and the ordinary `arcane dev` startup refresh.
4045
+ Selected app files retain their `apps/<id>/` paths alongside the shared routes
4046
+ and root launcher described by `packageApp()`.
4047
+
4048
+ Packaging does not automatically run tests or checks. Verification occurs only
4049
+ when explicitly requested or when required for the selected release output.
4050
+ A failure leaves the prior distribution untouched. Success returns the selected
4051
+ workspace and app identity plus `release`, containing the low-level package
4052
+ result, manifest, and complete file inventory. `ARCANE_APP_RELEASE.json` records
4053
+ that inventory and the distinct app-relative `app.entry` and package-relative
4054
+ `app.start`; it remains package metadata rather than an application route.
4052
4055
 
4053
4056
  ### Signature and result
4054
4057
 
@@ -4072,7 +4075,7 @@ const result = await packageApplication({
4072
4075
  appId: 'hello-world'
4073
4076
  });
4074
4077
 
4075
- console.log(result.release.importMap.documentPaths);
4078
+ console.log(result.release.manifest.app.start, result.release.files);
4076
4079
  ```
4077
4080
 
4078
4081
  ## planApplication()
@@ -391,3 +391,35 @@ review does not claim execution on those platforms or an actual provider send.
391
391
  The updated test source covers disposable JSON storage, exact named selection,
392
392
  preservation of other settings, missing settings, credential-free error output,
393
393
  cancellation before writes, and CLI defaults. Local tests and checks were not run.
394
+
395
+ ## HTTPS and HTTP/2 follow-up
396
+
397
+ The user explicitly selected HTTPS with HTTP/2. The mail listener remains on
398
+ its dedicated configured port, default 8025, with the published
399
+ `node-http-server` module owning TLS, protocol negotiation and listener/session
400
+ shutdown. This increment preserves the provider attempt, report content,
401
+ subscription callback, sender selection, recipient configuration and cancellation.
402
+
403
+ | Method or action | Gates | Decision, callers, and concrete purpose |
404
+ | --- | --- | --- |
405
+ | `readMailServerSettings` | Y/Y/N | Read the same `.env.json` once for server startup, select the existing provider profile and resolve the two shared PEM path settings relative to that file. This avoids a second configuration read and platform-specific credential or certificate processes. |
406
+ | `readMailProviderKey` and `serveMailGateway` | Y/Y/N | Preserve the existing credential-injection interface and missing-provider error while consuming the already-read startup key. Require the selected TLS pair before binding; forward only the paths to the listener. Direct provider sending and key CRUD remain unchanged. |
407
+ | `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH` | Y/Y/N | Clear top-level names identify certificate and private-key file paths for one listener, independently of any provider profile or calling application. Missing settings and unusable JSON values name the field without printing its content. |
408
+ | `startResendMailServer` TLS options | Y/Y/N | Add `certPath` and `keyPath` through the module's published PEM API. HTTPS-only mode creates one listener with HTTP/2 and HTTP/1.1 negotiation on the selected port. This internal source integration retains its existing HTTP behavior when neither path is supplied; it is not an npm package export. The public toolchain mail operation requires the JSON pair. |
409
+ | `listenForMailRequests` and returned URL | Y/Y/N | Observe the actual secure listener for bind errors and advertise HTTPS when selected. Preserve the friendly occupied-port failure, native cause, cancellation and owned shutdown. |
410
+ | Native mail header access and current-domain comparison | Y/Y/Y | Remove the HTTP/1-only assumption that every request has `headersDistinct` and `Host`. HTTP/2 supplies native `headers` and `authority`; consume those directly without rebuilding or splitting headers. Node owns its HTTP/2 duplicate-field behavior. |
411
+ | Certificate generation, platform trust changes, renewal watchers, duplicate PEM reads and a development-server import | N/N/Y | Add none. The operator supplies an existing certificate pair; the HTTP server module owns PEM loading. The development resolver has development-specific defaults and errors, so importing or extracting it adds no useful shared behavior for these two path resolutions. |
412
+
413
+ Ordinary startup performs one JSON read, two path resolutions and one listener
414
+ launch. Certificate file reads occur once at the module's transport owner.
415
+ There are no new per-request file reads, provider requests, polling loops or
416
+ certificate processes. These are source observations, not measured speedups.
417
+ The JSON and Node TLS paths are portable across Windows, Linux and macOS;
418
+ Android needs a compatible Node host and readable configuration/certificate files.
419
+
420
+ Focused test source covers TLS option selection, the secure listener's occupied
421
+ port, HTTP/2 header/authority handling, provider and subscription forwarding,
422
+ JSON paths and missing TLS settings. Synthetic TLS selection is not evidence of
423
+ an encrypted handshake. No local tests, checks, server launch, real TLS
424
+ negotiation or provider send were performed for this follow-up review. Selected
425
+ package and publication results belong to the delivery record.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
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",
package/src/cli/main.mjs CHANGED
@@ -86,6 +86,7 @@ Usage:
86
86
  ${CLI_NAME} mail key delete [profile]
87
87
  ${CLI_NAME} mail send [--profile <profile>] [--from <address>] --report-key <id> --report-stdin [--request-timeout <ms>]
88
88
  ${CLI_NAME} mail serve [--profile <profile>] [--from <address>] [--app <label>] [--origin <origin>] [--allow-to <addresses>] [--host 0.0.0.0] [--port 8025] [--request-timeout <ms>]
89
+ HTTPS/HTTP2; .env.json supplies RESEND_API_KEY, MAIL_TLS_CERT_PATH, and MAIL_TLS_KEY_PATH.
89
90
 
90
91
  Development:
91
92
  --public Bind dev to all IPv4 interfaces (0.0.0.0) and print network URLs.
@@ -516,15 +516,19 @@ async function packagedRoutes(releaseRoot){
516
516
  const canonical=await canonicalRealDirectory(requested,'Packaged release root');
517
517
  let pwa = false;
518
518
  let startPath = '/index.html';
519
+ try {
520
+ const release = JSON.parse(
521
+ await readFile(path.join(canonical, 'ARCANE_APP_RELEASE.json'), 'utf8')
522
+ );
523
+ const start = release.app.start ?? release.app.entry;
524
+ const startUrl = new URL(start, 'http://arcane.invalid/');
525
+ startPath = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
526
+ } catch (error) {
527
+ if (error.code !== 'ENOENT') throw error;
528
+ }
519
529
  try {
520
530
  const manifest = JSON.parse(await readFile(path.join(canonical, 'arcane-offline.json'), 'utf8'));
521
531
  pwa = manifest.schemaVersion === 1;
522
- if (pwa) {
523
- const release = JSON.parse(
524
- await readFile(path.join(canonical, 'ARCANE_APP_RELEASE.json'), 'utf8')
525
- );
526
- startPath = `/${release.app.entry}`;
527
- }
528
532
  } catch (error) {
529
533
  if (error.code !== 'ENOENT') throw error;
530
534
  }
@@ -111,6 +111,32 @@ export async function readMailCredential(options={}){
111
111
  return configuredMailKey(settings,location);
112
112
  }
113
113
 
114
+ export async function readMailServerSettings(options = {}) {
115
+ const location = mailCredentialLocation(options);
116
+ const settings = await readMailSettings(location.filePath, options.signal);
117
+ const serverSettings = (options.readCredential ?? null) === null
118
+ ? {apiKey: configuredMailKey(settings, location)}
119
+ : {};
120
+ const tlsSettings = {
121
+ MAIL_TLS_CERT_PATH: 'certPath',
122
+ MAIL_TLS_KEY_PATH: 'keyPath'
123
+ };
124
+ for (const [setting, option] of Object.entries(tlsSettings)) {
125
+ const value = settings[setting];
126
+ if (value === undefined || value === null || value === '') {
127
+ continue;
128
+ }
129
+ if (!is.string(value)) {
130
+ throw new ArcaneError(
131
+ ERROR_CODES.usage,
132
+ `${setting} in ${location.filePath} must be a PEM file path string.`
133
+ );
134
+ }
135
+ serverSettings[option] = path.resolve(path.dirname(location.filePath), value);
136
+ }
137
+ return serverSettings;
138
+ }
139
+
114
140
  export async function getMailCredentialStatus(options={}){
115
141
  const location=mailCredentialLocation(options);
116
142
  const settings=await readMailSettings(location.filePath,options.signal);
@@ -134,20 +134,33 @@ function createRequestId(factory){
134
134
  return randomUUID();
135
135
  }
136
136
 
137
- function requireRequestHeader(request,headerName){
138
- const headerValues=request.headersDistinct[headerName]??[];
139
- if(headerValues.length!==1||!headerValues[0]){
140
- throw new MailGatewayFault('mail_invalid_headers',{statusCode:400});
137
+ function readRequestHeader(request, headerName) {
138
+ const distinctHeaders = request.headersDistinct;
139
+ if (distinctHeaders) {
140
+ const headerValues = distinctHeaders[headerName] ?? [];
141
+ return headerValues.length === 1 ? headerValues[0] : undefined;
142
+ }
143
+ // HTTP/2 owns header normalization and does not expose headersDistinct.
144
+ return request.headers[headerName];
145
+ }
146
+
147
+ function requireRequestHeader(request, headerName) {
148
+ const headerValue = readRequestHeader(request, headerName);
149
+ if (!headerValue) {
150
+ throw new MailGatewayFault(
151
+ 'mail_invalid_headers',
152
+ {statusCode: 400}
153
+ );
141
154
  }
142
- return headerValues[0];
155
+ return headerValue;
143
156
  }
144
157
 
145
158
  async function verifyMailSubscription(request,configuration,signal){
146
159
  const appName=requireRequestHeader(request,'x-mail-app');
147
- const authorizationHeaders=request.headersDistinct.authorization??[];
148
- const subscriptionKey=authorizationHeaders.length===1
149
- ?/^Bearer (.+)$/iu.exec(authorizationHeaders[0])?.[1]
150
- :undefined;
160
+ const authorization = readRequestHeader(request, 'authorization');
161
+ const subscriptionKey = authorization
162
+ ? /^Bearer (.+)$/iu.exec(authorization)?.[1]
163
+ : undefined;
151
164
  if(!subscriptionKey){
152
165
  throw new MailGatewayFault('mail_subscription_required',{statusCode:401});
153
166
  }
@@ -869,10 +882,11 @@ function createConfiguredMailHandler(configuration){
869
882
  }
870
883
  const requestOrigin=request.headers.origin;
871
884
  if(requestOrigin){
885
+ const requestAuthority = request.authority || request.headers.host;
872
886
  const originAllowed=configuration.allowedOrigins.size>0
873
887
  ?configuration.allowedOrigins.has(requestOrigin)
874
- :requestOrigin===`https://${request.headers.host}`
875
- ||requestOrigin===`http://${request.headers.host}`;
888
+ :requestOrigin===`https://${requestAuthority}`
889
+ ||requestOrigin===`http://${requestAuthority}`;
876
890
  if(!originAllowed){
877
891
  throw new MailGatewayFault('mail_origin_not_allowed',{statusCode:403});
878
892
  }
@@ -1008,7 +1022,8 @@ function listenForMailRequests(mailServer){
1008
1022
  server.removeListener('error',onError);
1009
1023
  resolve(server);
1010
1024
  });
1011
- mailServer.server.once('error',onError);
1025
+ const listener = mailServer.secureServer ?? mailServer.server;
1026
+ listener.once('error', onError);
1012
1027
  });
1013
1028
  }
1014
1029
 
@@ -1017,11 +1032,24 @@ export async function startResendMailServer(options={}){
1017
1032
  if(configuration.signal?.aborted){
1018
1033
  throw configuration.signal.reason??new Error('Mail server start was cancelled.');
1019
1034
  }
1020
- const mailServer=new Server({
1021
- host:configuration.host,
1022
- port:configuration.port,
1023
- server:{timeout:0}
1024
- });
1035
+ if ((options.certPath === undefined) !== (options.keyPath === undefined)) {
1036
+ throw configurationError('Mail HTTPS requires both certPath and keyPath when either is supplied.');
1037
+ }
1038
+ const httpsSelected = options.certPath !== undefined;
1039
+ const mailServer = new Server(
1040
+ {
1041
+ host: configuration.host,
1042
+ port: configuration.port,
1043
+ server: {timeout: 0},
1044
+ https: httpsSelected ? {
1045
+ certificate: options.certPath,
1046
+ privateKey: options.keyPath,
1047
+ port: configuration.port,
1048
+ only: true,
1049
+ http2: true
1050
+ } : {}
1051
+ }
1052
+ );
1025
1053
  const requestHandler=createConfiguredMailHandler(configuration);
1026
1054
  mailServer.onRawRequest=function routeRawMailRequest(request,response){
1027
1055
  requestHandler.handle(request,response);
@@ -1050,7 +1078,7 @@ export async function startResendMailServer(options={}){
1050
1078
  throw configurationError('Mail server has no TCP listener address.');
1051
1079
  }
1052
1080
  const displayHost=address.address.includes(':')?`[${address.address}]`:address.address;
1053
- const origin=`http://${displayHost}:${String(address.port)}`;
1081
+ const origin = `${httpsSelected ? 'https' : 'http'}://${displayHost}:${String(address.port)}`;
1054
1082
  let closePromise=null;
1055
1083
  let resolveLifecycle;
1056
1084
  let rejectLifecycle;
package/src/mail.mjs CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  getMailCredentialStatus,
6
6
  mailCredentialLocation,
7
7
  readMailCredential,
8
+ readMailServerSettings,
8
9
  setMailCredential
9
10
  } from './mail-credentials.mjs';
10
11
  import {sendResendMail,startResendMailServer} from './mail-server.mjs';
@@ -125,10 +126,11 @@ async function sendMailFromReport(options){
125
126
  }
126
127
  }
127
128
 
128
- async function readMailProviderKey(options){
129
- const readCredential=resolveMailCommandDependency(options,'readCredential',readMailCredential);
129
+ async function readMailProviderKey(options, serverSettings){
130
130
  const credentialOptions=mailCredentialOptions(options);
131
- const apiKey=await readCredential(credentialOptions);
131
+ const apiKey = serverSettings !== undefined && (options.readCredential ?? null) === null
132
+ ? serverSettings.apiKey
133
+ : await resolveMailCommandDependency(options, 'readCredential', readMailCredential)(credentialOptions);
132
134
  if(apiKey===null){
133
135
  const location=mailCredentialLocation(credentialOptions);
134
136
  throw new ArcaneError(
@@ -147,10 +149,25 @@ async function readMailProviderKey(options){
147
149
 
148
150
  async function serveMailGateway(options){
149
151
  const startServer=resolveMailCommandDependency(options,'startServer',startResendMailServer);
152
+ const readServerSettings = resolveMailCommandDependency(options, 'readServerSettings', readMailServerSettings);
150
153
  throwIfAborted(options.signal);
151
- let apiKey=await readMailProviderKey(options);
154
+ const serverSettings = await readServerSettings(
155
+ {...mailCredentialOptions(options), readCredential: options.readCredential}
156
+ );
157
+ throwIfAborted(options.signal);
158
+ let apiKey=await readMailProviderKey(options, serverSettings);
152
159
  try{
153
160
  throwIfAborted(options.signal);
161
+ const missingSettings = [];
162
+ if (!serverSettings.certPath) missingSettings.push('MAIL_TLS_CERT_PATH');
163
+ if (!serverSettings.keyPath) missingSettings.push('MAIL_TLS_KEY_PATH');
164
+ if (missingSettings.length) {
165
+ const location = mailCredentialLocation(options);
166
+ throw new ArcaneError(
167
+ ERROR_CODES.prerequisiteMissing,
168
+ `Missing ${missingSettings.join(', ')} in ${location.filePath}. Mail HTTPS requires a certificate and private key.`
169
+ );
170
+ }
154
171
  const recipientAllowlist=mailRecipientOptions(options.allowTo,'allowTo');
155
172
  const errorRecipients=options.errorTo===undefined
156
173
  ? recipientAllowlist
@@ -162,10 +179,12 @@ async function serveMailGateway(options){
162
179
  ?[]
163
180
  :is.array(options.origin)?[...options.origin]:[options.origin],
164
181
  bodyTimeoutMs:options.bodyTimeoutMs,
182
+ certPath:serverSettings.certPath,
165
183
  errorRecipients,
166
184
  fetchImpl:options.fetchImpl,
167
185
  from:options.from,
168
186
  host:options.host??'0.0.0.0',
187
+ keyPath:serverSettings.keyPath,
169
188
  onEvent:options.onEvent,
170
189
  port:options.port,
171
190
  providerTimeoutMs:options.requestTimeout,
@@ -424,6 +424,15 @@ function destinationJoin(root,relative){
424
424
  return root==='.'?relative:`${root}/${relative}`;
425
425
  }
426
426
 
427
+ function appPackagePath(context, relative) {
428
+ return `apps/${context.appId}/${relative}`;
429
+ }
430
+
431
+ function packageResourceUrl(relative) {
432
+ const segments = relative.split('/');
433
+ return `./${segments.map(encodeURIComponent).join('/')}`;
434
+ }
435
+
427
436
  async function collectSelectedPath({
428
437
  sourceRoot,
429
438
  selected,
@@ -483,7 +492,7 @@ async function collectPackageRecords(context,{signal}={}){
483
492
  await collectSelectedPath({
484
493
  sourceRoot:context.appRoot,
485
494
  selected,
486
- destination:selected,
495
+ destination:appPackagePath(context, selected),
487
496
  excludes:context.config.exclude,
488
497
  reject:isAppSourceForbidden,
489
498
  records,
@@ -512,23 +521,25 @@ async function collectPackageRecords(context,{signal}={}){
512
521
  }
513
522
  }
514
523
  records.sort((left,right)=>compareText(left.destination,right.destination));
515
- if(!records.some(record=>pathKey(record.destination)===pathKey(context.config.entry))){
524
+ if(!records.some(record=>pathKey(record.destination)===pathKey(appPackagePath(context, context.config.entry)))){
516
525
  fail(`Package entry is missing from the selected files: ${context.config.entry}.`);
517
526
  }
518
527
  return records;
519
528
  }
520
529
 
521
- async function browserDocuments(records,entry){
530
+ async function browserDocuments(records,entry,appPrefix){
522
531
  let entryDocument=null;
523
532
  const documents=[];
524
533
  for(const record of records){
525
534
  const extension=path.posix.extname(record.destination).toLocaleLowerCase('en-US');
526
535
  if(extension!=='.html'&&extension!=='.htm')continue;
536
+ const documentPath=record.destination.startsWith(appPrefix)
537
+ ?record.destination.slice(appPrefix.length):record.destination;
527
538
  const inspected=inspectImportMapHtml(await readFile(record.source,'utf8'),{
528
- documentPath:record.destination
539
+ documentPath
529
540
  });
530
- const document={path:record.destination,...copyJson(inspected)};
531
- if(record.destination===entry){
541
+ const document={path:documentPath,packagePath:record.destination,...copyJson(inspected)};
542
+ if(record.destination===`${appPrefix}${entry}`){
532
543
  entryDocument=document;
533
544
  }else if(inspected.bases.length>0){
534
545
  documents.push(document);
@@ -567,8 +578,8 @@ async function inspectContext(context,{signal}={}){
567
578
  }),
568
579
  ...(context.config.adapter===undefined?{}:{adapter:context.config.adapter}),
569
580
  descriptor:await optionalDescriptor(context),
570
- browserDocuments:await browserDocuments(records,context.config.entry),
571
- files:records.map(record=>record.destination),
581
+ browserDocuments:await browserDocuments(records,context.config.entry,appPackagePath(context,'')),
582
+ files:[...new Set(['index.html',...records.map(record=>record.destination)])].sort(compareText),
572
583
  output:path.relative(context.workspaceRoot,context.outputRoot).split(path.sep).join('/')
573
584
  };
574
585
  }
@@ -611,6 +622,28 @@ async function copyRecords(records,stagingRoot,{signal,onEvent}={}){
611
622
  }
612
623
  }
613
624
 
625
+ async function writePackageLauncher(context, stagingRoot) {
626
+ function escapeHtml(value) {
627
+ return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;')
628
+ .replaceAll('<', '&lt;').replaceAll('>', '&gt;');
629
+ }
630
+ const start = escapeHtml(packageResourceUrl(appPackagePath(context, context.config.entry)));
631
+ const title = escapeHtml(context.config.displayName);
632
+ const content = `<!doctype html>
633
+ <html lang="en">
634
+ <head>
635
+ <meta charset="utf-8">
636
+ <meta http-equiv="refresh" content="0; url=${start}">
637
+ <title>${title}</title>
638
+ </head>
639
+ <body>
640
+ <a href="${start}">Open ${title}</a>
641
+ </body>
642
+ </html>
643
+ `;
644
+ await writeFile(path.join(stagingRoot, 'index.html'), content, 'utf8');
645
+ }
646
+
614
647
  async function listOutputFiles(root,{signal}={}){
615
648
  const files=[];
616
649
  async function visit(directory,relativeRoot=''){
@@ -652,6 +685,7 @@ function releaseManifest(context,files,pwaArtifacts){
652
685
  displayName:context.config.displayName,
653
686
  version:context.config.version,
654
687
  entry:context.config.entry,
688
+ start:packageResourceUrl(appPackagePath(context, context.config.entry)),
655
689
  strategy:context.config.strategy,
656
690
  shared:[...context.config.shared],
657
691
  ...(pwaArtifacts?{pwa:{
@@ -695,6 +729,8 @@ async function replaceDirectory(stagingRoot,outputRoot){
695
729
  async function packageWithContext(context,options={}){
696
730
  const {signal,onEvent,browserPwa=true}=options;
697
731
  const pwaEnabled=browserPwa&&context.config.pwa?.enabled===true;
732
+ const appPath=`apps/${context.appId}`;
733
+ const entryPath=appPackagePath(context,context.config.entry);
698
734
  const inspected=await inspectContext(context,{signal});
699
735
  if(options.dryRun){
700
736
  return {
@@ -724,7 +760,14 @@ async function packageWithContext(context,options={}){
724
760
  let promoted=false;
725
761
  try{
726
762
  const records=await collectPackageRecords(context,{signal});
727
- const copyBase=()=>copyRecords(records,stagingRoot,{signal,onEvent});
763
+ async function copyBase() {
764
+ await copyRecords(records,stagingRoot,{signal,onEvent});
765
+ if (!records.some(function selectedRootIndex(record) {
766
+ return record.destination === 'index.html';
767
+ })) {
768
+ await writePackageLauncher(context, stagingRoot);
769
+ }
770
+ }
728
771
  const adapter=await loadAdapter(context);
729
772
  if(adapter){
730
773
  await adapter.buildArcanePackage({
@@ -745,21 +788,22 @@ async function packageWithContext(context,options={}){
745
788
  const assetVersion=await readWorkspaceAssetVersion(context.workspaceRoot);
746
789
  const version=pwaEnabled?null:assetVersion;
747
790
  const inventory=new Set(files);
748
- const offlineInventory=pwaEnabled?new Set(selectPwaFiles(files,context.config.pwa)):null;
791
+ const offlineInventory=pwaEnabled?new Set(selectPwaFiles(files,context.config.pwa,appPath)):null;
749
792
  const offlineReferences=new Set();
750
- const entryUrl=new URL(context.config.entry,'http://arcane.invalid/');
793
+ const entryUrl=new URL(packageResourceUrl(entryPath),'http://arcane.invalid/');
751
794
  const pwaDocumentSources=new Map();
752
795
  if(pwaEnabled){
753
- const documentPaths=new Set([context.config.entry]);
796
+ const documentPaths=new Set([entryPath]);
754
797
  for(const selected of context.config.include){
755
- if(/\.html?$/iu.test(selected)&&inventory.has(selected))documentPaths.add(selected);
798
+ const selectedPath=appPackagePath(context,selected);
799
+ if(/\.html?$/iu.test(selected)&&inventory.has(selectedPath))documentPaths.add(selectedPath);
756
800
  }
757
801
  for(const document of inspected.browserDocuments){
758
802
  const appDocument=context.config.include.some(function includesAppDocument(selected){
759
803
  return sameOrDescendant(document.path,selected);
760
804
  });
761
- if(appDocument&&document.managedMaps.length>0&&inventory.has(document.path)){
762
- documentPaths.add(document.path);
805
+ if(appDocument&&document.managedMaps.length>0&&inventory.has(document.packagePath)){
806
+ documentPaths.add(document.packagePath);
763
807
  }
764
808
  }
765
809
  await Promise.all(
@@ -777,13 +821,13 @@ async function packageWithContext(context,options={}){
777
821
  )
778
822
  );
779
823
  }
780
- const entryDocument=pwaEnabled?pwaDocumentSources.get(context.config.entry).inspected
824
+ const entryDocument=pwaEnabled?pwaDocumentSources.get(entryPath).inspected
781
825
  :inspected.browserDocuments.find(function matchingEntryDocument(document){
782
826
  return document.path===context.config.entry;
783
827
  });
784
828
  const documentUrl=entryDocument?.bases[0]?.href
785
829
  ?new URL(entryDocument.bases[0].href,entryUrl):entryUrl;
786
- const pending=[{file:context.config.entry,documentUrl}];
830
+ const pending=[{file:entryPath,documentUrl}];
787
831
  for(const file of files){
788
832
  if(/^arcane\/(?:modules|entities|components|css|sdk|dependencies)\//u.test(file)
789
833
  &&/\.(?:m?js|html?|css)$/iu.test(file))pending.push({file,documentUrl});
@@ -792,15 +836,15 @@ async function packageWithContext(context,options={}){
792
836
  }
793
837
  }
794
838
  for(const document of inspected.browserDocuments){
795
- if(document.managedMaps.length>0&&!pwaDocumentSources.has(document.path)){
796
- const url=new URL(document.path,entryUrl.origin);
797
- pending.push({file:document.path,documentUrl:document.bases[0]?.href
839
+ if(document.managedMaps.length>0&&!pwaDocumentSources.has(document.packagePath)){
840
+ const url=new URL(packageResourceUrl(document.packagePath),entryUrl.origin);
841
+ pending.push({file:document.packagePath,documentUrl:document.bases[0]?.href
798
842
  ?new URL(document.bases[0].href,url):url});
799
843
  }
800
844
  }
801
845
  for(const [file,document] of pwaDocumentSources){
802
- if(file===context.config.entry)continue;
803
- const url=new URL(file,entryUrl.origin);
846
+ if(file===entryPath)continue;
847
+ const url=new URL(packageResourceUrl(file),entryUrl.origin);
804
848
  pending.push({file,documentUrl:document.inspected.bases[0]?.href
805
849
  ?new URL(document.inspected.bases[0].href,url):url});
806
850
  }
@@ -832,7 +876,7 @@ async function packageWithContext(context,options={}){
832
876
  if(!pwaEnabled&&!traversable)continue;
833
877
  if(kind==='import'&&!/^(?:\.{1,2}\/|\/)/u.test(url))continue;
834
878
  try{
835
- const ownerUrl=new URL(relative,entryUrl.origin);
879
+ const ownerUrl=new URL(packageResourceUrl(relative),entryUrl.origin);
836
880
  const base=baseHref?new URL(baseHref,ownerUrl)
837
881
  :baseKind==='document'||path.posix.basename(relative)==='arcane.importmap.json'
838
882
  ?current.documentUrl:ownerUrl;
@@ -854,7 +898,7 @@ async function packageWithContext(context,options={}){
854
898
  if(files.some(file=>pathKey(file)===pathKey(RELEASE_MANIFEST_NAME))){
855
899
  fail(`Package content must not author ${RELEASE_MANIFEST_NAME}.`);
856
900
  }
857
- if(!files.some(file=>pathKey(file)===pathKey(context.config.entry))){
901
+ if(!files.some(file=>pathKey(file)===pathKey(entryPath))){
858
902
  fail(`Package output is missing its entry file: ${context.config.entry}.`);
859
903
  }
860
904
  const pwaArtifacts=pwaEnabled?createPwaArtifacts({
@@ -862,8 +906,9 @@ async function packageWithContext(context,options={}){
862
906
  id:context.appId,
863
907
  displayName:context.config.displayName,
864
908
  version:context.config.version,
865
- entry:context.config.entry
909
+ entry:packageResourceUrl(entryPath)
866
910
  },
911
+ appPath,
867
912
  sdkVersion:assetVersion,
868
913
  pwa:context.config.pwa,
869
914
  files,
@@ -878,7 +923,7 @@ async function packageWithContext(context,options={}){
878
923
  files.push(artifact.path);
879
924
  }
880
925
  for(const [documentPath,document] of pwaDocumentSources){
881
- const documentUrl=new URL(documentPath,entryUrl.origin);
926
+ const documentUrl=new URL(packageResourceUrl(documentPath),entryUrl.origin);
882
927
  const outputBase=document.inspected.bases[0]?.href
883
928
  ?new URL(document.inspected.bases[0].href,documentUrl):documentUrl;
884
929
  const outputDirectory=new URL('./',outputBase).pathname;
package/src/pwa.mjs CHANGED
@@ -72,17 +72,20 @@ function selectedPath(file, selection) {
72
72
  return file === selection || file.startsWith(prefix);
73
73
  }
74
74
 
75
- export function selectPwaFiles(files, pwa) {
75
+ export function selectPwaFiles(files, pwa, appPath = '') {
76
76
  const config = normalizePwaConfig(pwa);
77
77
  const include = config?.offline.include ?? [];
78
78
  const exclude = config?.offline.exclude ?? [];
79
+ const appPrefix = appPath ? `${appPath}/` : '';
79
80
  return files.filter(
80
81
  function selectedOfflineFile(file) {
82
+ const relative = appPrefix && file.startsWith(appPrefix)
83
+ ? file.slice(appPrefix.length) : file;
81
84
  function includedPath(selection) {
82
- return selectedPath(file, selection);
85
+ return selectedPath(relative, selection);
83
86
  }
84
87
  function excludedPath(selection) {
85
- return selectedPath(file, selection);
88
+ return selectedPath(relative, selection);
86
89
  }
87
90
  return (include.length === 0 || include.some(includedPath))
88
91
  && !exclude.some(excludedPath);
@@ -199,6 +202,7 @@ export function createPwaArtifacts(
199
202
  mode = 'release',
200
203
  runtimeBase = './arcane/sdk/',
201
204
  appBase,
205
+ appPath = '',
202
206
  navigationAliases,
203
207
  revision
204
208
  } = {}
@@ -209,20 +213,19 @@ export function createPwaArtifacts(
209
213
  throw new TypeError('PWA mode must be release or development.');
210
214
  }
211
215
  const entryUrl = new URL(app.entry, 'https://arcane.invalid/');
212
- const applicationBase = appBase ?? (mode === 'development'
213
- ? new URL('./', entryUrl).pathname : './');
214
- const manifest = applicationManifest(
215
- {
216
- id: applicationBase,
217
- name: app.displayName,
218
- short_name: app.displayName,
219
- start_url: app.entry,
220
- scope: applicationBase,
221
- display: 'standalone',
222
- ...config.manifest
223
- },
224
- applicationBase
225
- );
216
+ const applicationBase = appBase ?? (appPath ? `./${appPath}/`
217
+ : mode === 'development' ? new URL('./', entryUrl).pathname : './');
218
+ // Relocating app files must not change an existing installed app's default identity.
219
+ const installationBase = appBase ?? (mode === 'development' ? applicationBase : './');
220
+ const manifest = {
221
+ id: installationBase,
222
+ name: app.displayName,
223
+ short_name: app.displayName,
224
+ start_url: manifestUrl(app.entry, appPath ? basePath : applicationBase),
225
+ scope: installationBase,
226
+ display: 'standalone',
227
+ ...applicationManifest(config.manifest, applicationBase)
228
+ };
226
229
  const generatedAssets = [
227
230
  PWA_MANIFEST_NAME,
228
231
  PWA_OFFLINE_MANIFEST_NAME,
@@ -232,7 +235,7 @@ export function createPwaArtifacts(
232
235
  return resourceUrl(basePath, file);
233
236
  }
234
237
  );
235
- const selectedFiles = selectPwaFiles(files, config);
238
+ const selectedFiles = selectPwaFiles(files, config, appPath);
236
239
  const selectedAssets = [
237
240
  ...selectedFiles.map(
238
241
  function packagedResource(file) {