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 +34 -0
- package/README.md +4 -3
- package/docs/architecture.md +18 -0
- package/docs/reference/cli.md +44 -19
- package/docs/reference/mail.md +40 -4
- package/docs/reference/pwa.md +16 -2
- package/docs/reference/sdk-api.md +36 -33
- package/docs/reviews/mail-server-purpose-review.md +32 -0
- package/package.json +1 -1
- package/src/cli/main.mjs +1 -0
- package/src/dev-server.mjs +10 -6
- package/src/mail-credentials.mjs +26 -0
- package/src/mail-server.mjs +46 -18
- package/src/mail.mjs +23 -4
- package/src/packager/core.mjs +71 -26
- package/src/pwa.mjs +21 -18
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.
|
|
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
|
|
27
|
-
|
|
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.
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
package/docs/reference/cli.md
CHANGED
|
@@ -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
|
|
199
|
-
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
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
|
package/docs/reference/mail.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
package/docs/reference/pwa.md
CHANGED
|
@@ -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
|
|
43
|
-
|
|
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
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
`
|
|
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.
|
|
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
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
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.
|
|
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
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.
|
package/src/dev-server.mjs
CHANGED
|
@@ -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
|
}
|
package/src/mail-credentials.mjs
CHANGED
|
@@ -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);
|
package/src/mail-server.mjs
CHANGED
|
@@ -134,20 +134,33 @@ function createRequestId(factory){
|
|
|
134
134
|
return randomUUID();
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
function
|
|
138
|
-
const
|
|
139
|
-
if(
|
|
140
|
-
|
|
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
|
|
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
|
|
148
|
-
const subscriptionKey=
|
|
149
|
-
|
|
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://${
|
|
875
|
-
||requestOrigin===`http://${
|
|
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
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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,
|
package/src/packager/core.mjs
CHANGED
|
@@ -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
|
|
539
|
+
documentPath
|
|
529
540
|
});
|
|
530
|
-
const document={path:record.destination,...copyJson(inspected)};
|
|
531
|
-
if(record.destination
|
|
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('&', '&').replaceAll('"', '"')
|
|
628
|
+
.replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
-
|
|
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(
|
|
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([
|
|
796
|
+
const documentPaths=new Set([entryPath]);
|
|
754
797
|
for(const selected of context.config.include){
|
|
755
|
-
|
|
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.
|
|
762
|
-
documentPaths.add(document.
|
|
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(
|
|
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:
|
|
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.
|
|
796
|
-
const url=new URL(document.
|
|
797
|
-
pending.push({file:document.
|
|
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===
|
|
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(
|
|
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:
|
|
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(
|
|
85
|
+
return selectedPath(relative, selection);
|
|
83
86
|
}
|
|
84
87
|
function excludedPath(selection) {
|
|
85
|
-
return selectedPath(
|
|
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 ?? (
|
|
213
|
-
? new URL('./', entryUrl).pathname : './');
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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) {
|