arcane-os 0.20.0 → 0.22.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 +30 -0
- package/README.md +4 -3
- package/docs/architecture.md +10 -0
- package/docs/reference/cli.md +26 -2
- package/docs/reference/inventory/package-api.json +17 -1
- package/docs/reference/mail.md +40 -4
- package/docs/reference/protocols.md +25 -1
- package/docs/reference/sdk-api.md +156 -0
- package/docs/reviews/mail-server-purpose-review.md +32 -0
- package/package.json +1 -1
- package/src/cli/main.mjs +1 -0
- package/src/import-map.mjs +321 -7
- package/src/index.mjs +1 -0
- package/src/mail-credentials.mjs +26 -0
- package/src/mail-server.mjs +46 -18
- package/src/mail.mjs +23 -4
- package/src/runtime.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
- Export `generateDocumentImportMaps()` from `arcane-os` for explicitly selected
|
|
6
|
+
host HTML documents and an existing materialized runtime. Generate each
|
|
7
|
+
document's SDK import URLs from its authored base without app discovery or
|
|
8
|
+
imposing an application layout.
|
|
9
|
+
- Preserve complete authored HTML, resource URLs, custom import maps and script
|
|
10
|
+
loading order while updating only SDK-managed map blocks. Encode runtime
|
|
11
|
+
filenames and rebase both targets and URL compatibility keys.
|
|
12
|
+
- Inventory the selected runtime once per batch, prepare every document before
|
|
13
|
+
writing, and expose ordered write events, cancellation and event-delivery
|
|
14
|
+
failures. Document the public API and add focused behavioral test source.
|
|
15
|
+
- Preserve the platform's existing cancellation code in the import-map and
|
|
16
|
+
runtime-inventory paths, including ordinary `AbortController.abort()`.
|
|
17
|
+
|
|
18
|
+
## 0.21.0
|
|
19
|
+
|
|
20
|
+
- Serve mail over HTTPS with HTTP/2 through the published `node-http-server`
|
|
21
|
+
PEM API, with HTTP/1.1 fallback on the same selected port, default 8025.
|
|
22
|
+
Preserve the friendly occupied-port failure and owned listener lifecycle.
|
|
23
|
+
- Read `MAIL_TLS_CERT_PATH` and `MAIL_TLS_KEY_PATH` with the provider profile
|
|
24
|
+
from `.env.json` in one startup read. Resolve relative PEM paths from that
|
|
25
|
+
file's directory and report missing TLS settings before binding. Keep file
|
|
26
|
+
and TLS handling portable across Windows, Linux and macOS.
|
|
27
|
+
- Use native HTTP/2 headers and authority for the mail and CORS contracts,
|
|
28
|
+
preserving exact report content, subscription verification, provider
|
|
29
|
+
outcomes, existing credential injection and cancellation.
|
|
30
|
+
- Extend the mail reference and purpose-gate report with TLS ownership and
|
|
31
|
+
configuration; add focused protocol and configuration test source.
|
|
32
|
+
|
|
3
33
|
## 0.20.0
|
|
4
34
|
|
|
5
35
|
- Restore repository-shaped application packages: selected app files remain
|
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.22.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
|
@@ -74,6 +74,16 @@ migration; the current tree has no OS-to-SDK synchronization path.
|
|
|
74
74
|
|
|
75
75
|
## Workspace profiles
|
|
76
76
|
|
|
77
|
+
Hosts with an existing HTML layout can call the root-exported
|
|
78
|
+
[`generateDocumentImportMaps()`](reference/sdk-api.md#generatedocumentimportmaps)
|
|
79
|
+
with a document root, explicit page paths, and an already materialized runtime.
|
|
80
|
+
This SDK-owned operation builds the shared map once and renders each page's
|
|
81
|
+
URLs from its authored base, preserving page content and script order. It
|
|
82
|
+
updates only SDK-managed inline maps; the host owns document selection,
|
|
83
|
+
runtime materialization, serving, and coordination with other writers. This
|
|
84
|
+
path requires no application descriptor or `apps/<id>/` layout and leaves the
|
|
85
|
+
app-scoped toolchain operation unchanged.
|
|
86
|
+
|
|
77
87
|
An external workspace maps the exact runtime shipped by its locked `arcane-os`
|
|
78
88
|
dependency. An Arcane OS checkout is an integrated SDK consumer, not the owner
|
|
79
89
|
of portable runtime source. For live shared development, the explicit
|
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. |
|
|
@@ -906,7 +906,7 @@ loss after the attempt begins is ambiguous because Resend may have accepted it.
|
|
|
906
906
|
|
|
907
907
|
### Mail gateway
|
|
908
908
|
|
|
909
|
-
`mail serve` starts one owned
|
|
909
|
+
`mail serve` starts one owned HTTPS gateway with HTTP/2:
|
|
910
910
|
|
|
911
911
|
```text
|
|
912
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>]
|
|
@@ -914,6 +914,30 @@ arcane mail serve [--profile <profile>] [--from <verified-sender>] [--app <label
|
|
|
914
914
|
|
|
915
915
|
The selected `.env.json` profile supplies only the server-side Resend API key;
|
|
916
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
|
+
|
|
917
941
|
The CLI does not read a browser app key. Its optional `--app` value labels the
|
|
918
942
|
server; the incoming request's `X-Mail-App` identifies the application for
|
|
919
943
|
subscription verification. The HTTP authentication contract pairs that
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"minimumVersion": "22.23.2 for Node entrypoints",
|
|
7
7
|
"moduleSystem": "ESM"
|
|
8
8
|
},
|
|
9
|
-
"memberCount":
|
|
9
|
+
"memberCount": 210,
|
|
10
10
|
"members": [
|
|
11
11
|
{
|
|
12
12
|
"id": "root:APP_BUNDLE_DESCRIPTOR_NAME",
|
|
@@ -772,6 +772,22 @@
|
|
|
772
772
|
"protocol": "Node ESM",
|
|
773
773
|
"normalization": "ArcaneError and JSON-safe error normalization"
|
|
774
774
|
},
|
|
775
|
+
{
|
|
776
|
+
"id": "root:generateDocumentImportMaps",
|
|
777
|
+
"name": "generateDocumentImportMaps",
|
|
778
|
+
"displayName": "generateDocumentImportMaps()",
|
|
779
|
+
"kind": "function",
|
|
780
|
+
"signature": "async generateDocumentImportMaps({documentRoot, runtimeRoot, documents, version=SDK_VERSION, deploymentUrl, signal, onEvent}={})",
|
|
781
|
+
"entrypoints": [
|
|
782
|
+
"arcane-os"
|
|
783
|
+
],
|
|
784
|
+
"primaryImport": "arcane-os",
|
|
785
|
+
"group": "Runtime and app descriptors",
|
|
786
|
+
"summary": "Writes SDK-managed inline import maps for explicit host documents using an existing materialized runtime and each document's authored base.",
|
|
787
|
+
"availability": "Node on Windows, Linux, and macOS",
|
|
788
|
+
"protocol": "Explicit host documents",
|
|
789
|
+
"normalization": "Absolute filesystem result paths, document-relative browser maps, ordered write events, cancellation, and degraded event-delivery reporting"
|
|
790
|
+
},
|
|
775
791
|
{
|
|
776
792
|
"id": "root:getSdkBrowserRuntimeRoot",
|
|
777
793
|
"name": "getSdkBrowserRuntimeRoot",
|
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.
|
|
@@ -47,7 +47,7 @@ These protocols normalize orchestration and results. They do not normalize a
|
|
|
47
47
|
Windows EXE, Linux DEB, Android APK, and portable directory into the same
|
|
48
48
|
artifact kind.
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
App-scoped managed browser imports have three supported control-plane entrypoints. The CLI
|
|
51
51
|
uses `arcane import-map`; Node callers use
|
|
52
52
|
`executeOperation('import-map', options)` or
|
|
53
53
|
`createToolchain(defaults).importMap(options)`. These are three routes to the
|
|
@@ -70,6 +70,9 @@ subpath owns shared speech-input cleanup and has the same
|
|
|
70
70
|
managed browser key. There is
|
|
71
71
|
no exported `importMapApplication()` function, `generateImportMap()` function, or
|
|
72
72
|
`arcane-os/import-map` package subpath.
|
|
73
|
+
Explicit host document lists use the separate root-exported
|
|
74
|
+
[`generateDocumentImportMaps()`](sdk-api.md#generatedocumentimportmaps) API
|
|
75
|
+
described below; they do not enter app discovery.
|
|
73
76
|
|
|
74
77
|
The application dependency boundary is conditional. Only an application that
|
|
75
78
|
actually consumes Arcane declares one exact published `arcane-os` version in
|
|
@@ -147,6 +150,27 @@ unsafe structure and grants no Core capability or provider authority.
|
|
|
147
150
|
See [EventManager and time-travel review](event-manager.md) for the callable
|
|
148
151
|
surface, DOM privacy defaults, playback modes, and recovery behavior.
|
|
149
152
|
|
|
153
|
+
## Explicit host documents
|
|
154
|
+
|
|
155
|
+
`generateDocumentImportMaps()` accepts one document root, an existing
|
|
156
|
+
materialized runtime root, and explicit document-root-relative paths. It
|
|
157
|
+
inventories the runtime and constructs the shared browser map once per call,
|
|
158
|
+
then resolves each document's map against its first href-bearing base or its
|
|
159
|
+
own URL. An absolute or root-relative base, or a relative base traversing above
|
|
160
|
+
the document root, requires the caller's directory `deploymentUrl`; target-only
|
|
161
|
+
bases leave URL resolution unchanged.
|
|
162
|
+
|
|
163
|
+
The operation preserves authored content, resource URLs, custom import maps,
|
|
164
|
+
and script order, updating only SDK-managed inline map blocks. It prepares all
|
|
165
|
+
documents before sequential writes and reports `import-map.documents.started`,
|
|
166
|
+
`import-map.write.progress`, and `import-map.documents.completed`. Cancellation
|
|
167
|
+
can leave completed document writes in place. Successful writes remain
|
|
168
|
+
successful when event delivery fails, with a degraded-delivery record in the
|
|
169
|
+
return value. Runtime materialization, serving, and writer coordination remain
|
|
170
|
+
with the caller. See the [API guide](sdk-api.md#generatedocumentimportmaps) for
|
|
171
|
+
the complete inputs, results, errors, and example. The app-scoped discovery and
|
|
172
|
+
map-artifact lifecycle below remains separate.
|
|
173
|
+
|
|
150
174
|
## Browser runtime delivery
|
|
151
175
|
|
|
152
176
|
External and modern integrated workspaces keep the same application URLs and a
|
|
@@ -153,6 +153,7 @@ browser map are cataloged separately in [Runtime modules](runtime-modules.md).
|
|
|
153
153
|
| `executeNativeBuildPlan()` | function | `arcane-os` | Targets, native plans, and providers | Node; selected browser/native target or provider as documented |
|
|
154
154
|
| `executeOperation()` | function | `arcane-os` | Headless toolchain operations | Node; selected operation may produce browser or native output |
|
|
155
155
|
| `fail()` | function | `arcane-os` | Errors | Node |
|
|
156
|
+
| `generateDocumentImportMaps()` | function | `arcane-os` | Runtime and app descriptors | Node; explicit documents and an existing materialized runtime |
|
|
156
157
|
| `getSdkBrowserRuntimeRoot()` | function | `arcane-os` | Runtime and app descriptors | Node |
|
|
157
158
|
| `getSdkRoot()` | function | `arcane-os` | Runtime and app descriptors | Node |
|
|
158
159
|
| `getTargetAdapter()` | function | `arcane-os` | Targets, native plans, and providers | Node; selected browser/native target or provider as documented |
|
|
@@ -1137,6 +1138,161 @@ import {APP_DESCRIPTOR_SCHEMA_VERSION} from 'arcane-os';
|
|
|
1137
1138
|
console.log(APP_DESCRIPTOR_SCHEMA_VERSION);
|
|
1138
1139
|
```
|
|
1139
1140
|
|
|
1141
|
+
## generateDocumentImportMaps()
|
|
1142
|
+
|
|
1143
|
+
### Overview
|
|
1144
|
+
|
|
1145
|
+
Writes SDK-managed inline browser import maps into explicitly selected HTML
|
|
1146
|
+
documents. Hosts can keep their existing page layout and use an already
|
|
1147
|
+
materialized Arcane runtime without an app descriptor, app discovery, or an
|
|
1148
|
+
`apps/<id>/` directory. The existing app-scoped `arcane import-map` operation
|
|
1149
|
+
continues to own descriptor-selected application pages and its map artifact.
|
|
1150
|
+
|
|
1151
|
+
### Signature and result
|
|
1152
|
+
|
|
1153
|
+
```text
|
|
1154
|
+
async generateDocumentImportMaps({
|
|
1155
|
+
documentRoot,
|
|
1156
|
+
runtimeRoot = path.join(documentRoot, 'arcane'),
|
|
1157
|
+
documents,
|
|
1158
|
+
version = SDK_VERSION,
|
|
1159
|
+
deploymentUrl,
|
|
1160
|
+
signal,
|
|
1161
|
+
onEvent
|
|
1162
|
+
} = {})
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
Import it from `arcane-os`.
|
|
1166
|
+
|
|
1167
|
+
| Input | Contract |
|
|
1168
|
+
| --- | --- |
|
|
1169
|
+
| `documentRoot` | Required filesystem directory containing the selected documents. Returned root paths are absolute. |
|
|
1170
|
+
| `runtimeRoot` | Existing materialized runtime directory containing `modules/`, `entities/`, `sdk/`, and its other selected content. Defaults to `arcane/` under `documentRoot`. Pass an absolute path when selecting another directory; relative root arguments resolve from the current working directory. |
|
|
1171
|
+
| `documents` | Required nonempty array of document-root-relative file paths, such as `['shell/home.html', 'settings.html']`. Use forward slashes and normalized paths without a leading `./` or parent traversal. Duplicate paths are processed once, in first-selected order. |
|
|
1172
|
+
| `version` | Version used in generated import-map targets and URL compatibility keys. Defaults to the installed SDK's `SDK_VERSION`; `null` selects unversioned map URLs. Authored resource URLs outside the managed map stay unchanged. |
|
|
1173
|
+
| `deploymentUrl` | Optional absolute directory URL ending in `/` that corresponds to `documentRoot`, such as `https://example.com/control/`. Required when a selected document has an absolute or root-relative base URL, or a relative base that traverses above `documentRoot`. |
|
|
1174
|
+
| `signal` | Optional `AbortSignal`, observed during inventory, document preparation, and before each write. |
|
|
1175
|
+
| `onEvent` | Optional named callback receiving the ordered events below. An asynchronous callback is awaited before the operation continues. |
|
|
1176
|
+
|
|
1177
|
+
The resolved result contains:
|
|
1178
|
+
|
|
1179
|
+
```text
|
|
1180
|
+
{
|
|
1181
|
+
documentRoot,
|
|
1182
|
+
runtimeRoot,
|
|
1183
|
+
documentPaths,
|
|
1184
|
+
documentCount,
|
|
1185
|
+
documents: [{path, filePath, imports}],
|
|
1186
|
+
committed: true
|
|
1187
|
+
}
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
`documentPaths` and each `filePath` are absolute filesystem paths. Each `path`
|
|
1191
|
+
is relative to `documentRoot`; `imports` is the complete map rendered for that
|
|
1192
|
+
document. The operation inventories the runtime once, builds the shared map
|
|
1193
|
+
once, and reads the selected documents with up to four concurrent readers.
|
|
1194
|
+
It renders every document before writing any of them, then writes in selected
|
|
1195
|
+
order. `committed: true` means all selected writes completed; the batch is not
|
|
1196
|
+
an atomic filesystem transaction.
|
|
1197
|
+
|
|
1198
|
+
### Document URLs and authored content
|
|
1199
|
+
|
|
1200
|
+
The first `<base>` with an `href` determines each document's effective base.
|
|
1201
|
+
A target-only base, such as `<base target="_blank">`, does not change URL
|
|
1202
|
+
resolution. With no base, `shell/home.html` resolves the default runtime through
|
|
1203
|
+
`../arcane/`. With `<base href="../">`, the same page resolves it through
|
|
1204
|
+
`./arcane/`. Generated URLs include the selected version when one is supplied.
|
|
1205
|
+
|
|
1206
|
+
The host serves the runtime at the same relative location as `runtimeRoot`
|
|
1207
|
+
has to `documentRoot`. `deploymentUrl` supplies that directory's public URL
|
|
1208
|
+
when an authored base needs an origin or deployment path to resolve correctly.
|
|
1209
|
+
For example, `https://example.com/control/` corresponds to `documentRoot`, so
|
|
1210
|
+
its default runtime is served at `https://example.com/control/arcane/`.
|
|
1211
|
+
Cross-origin bases produce absolute runtime map URLs. Without `deploymentUrl`,
|
|
1212
|
+
an absolute or root-relative base causes a `TypeError` before any document is
|
|
1213
|
+
written. A relative base that traverses above `documentRoot` also requires
|
|
1214
|
+
`deploymentUrl`, even if later path segments return into the physical directory:
|
|
1215
|
+
the filesystem's parent names do not establish public deployment paths.
|
|
1216
|
+
The two filesystem roots must share a volume.
|
|
1217
|
+
|
|
1218
|
+
The generator replaces only SDK-marked `<script type="importmap"
|
|
1219
|
+
data-arcane-import-map>` ranges, consolidating multiple SDK blocks into one.
|
|
1220
|
+
It preserves complete authored HTML around those ranges, including whitespace,
|
|
1221
|
+
resource URLs, custom import maps, and the attributes and order of classic,
|
|
1222
|
+
module, `async`, and `defer` scripts. Custom maps remain separate and are not
|
|
1223
|
+
merged into the SDK map. The managed block follows the effective base and
|
|
1224
|
+
precedes executable scripts and module preloads. An authored base that follows
|
|
1225
|
+
one of those loads is reported as `ARCANE_IMPORT_MAP_INVALID` before writes.
|
|
1226
|
+
|
|
1227
|
+
This operation writes only the selected HTML files. The caller owns runtime
|
|
1228
|
+
materialization, document selection, serving, and coordination with other
|
|
1229
|
+
document writers. It creates no map sidecar, copies no runtime content, and
|
|
1230
|
+
starts no build or browser.
|
|
1231
|
+
|
|
1232
|
+
### Events, errors, and cancellation
|
|
1233
|
+
|
|
1234
|
+
| Event | Fields |
|
|
1235
|
+
| --- | --- |
|
|
1236
|
+
| `import-map.documents.started` | `documentRoot`, `runtimeRoot`, and the complete `documentPaths` selection. |
|
|
1237
|
+
| `import-map.write.progress` | `paths`: all absolute document paths successfully written so far. |
|
|
1238
|
+
| `import-map.documents.completed` | The successful result fields, including `committed: true`. |
|
|
1239
|
+
|
|
1240
|
+
If an event callback throws or rejects, generation continues. A successful
|
|
1241
|
+
return additionally reports `eventDelivery: {status: 'degraded', errorCode:
|
|
1242
|
+
'ARCANE_EVENT_DELIVERY_FAILED', message}` for the first callback failure.
|
|
1243
|
+
|
|
1244
|
+
Invalid options can throw `TypeError`; document/map structure failures use
|
|
1245
|
+
`ARCANE_IMPORT_MAP_INVALID`, runtime inventory failures use
|
|
1246
|
+
`ARCANE_RUNTIME_INVALID`, and filesystem failures propagate. All started
|
|
1247
|
+
preparation reads settle before rejection; multiple failures are
|
|
1248
|
+
reported together in an `AggregateError`. Cancellation
|
|
1249
|
+
rejects with the signal's error reason when supplied, otherwise an operation
|
|
1250
|
+
cancellation error; an error lacking a code receives `ARCANE_CANCELLED`.
|
|
1251
|
+
Preparation failures occur before writes. Cancellation or a filesystem failure
|
|
1252
|
+
during writing can leave earlier completed files updated; the operation does
|
|
1253
|
+
not roll those files back. An in-flight filesystem write completes before the
|
|
1254
|
+
next cancellation boundary.
|
|
1255
|
+
|
|
1256
|
+
### Availability and normalization
|
|
1257
|
+
|
|
1258
|
+
**Node on Windows, Linux, and macOS.** Filesystem paths are resolved with the
|
|
1259
|
+
host path API; browser import URLs use forward slashes and URL encoding. Deep
|
|
1260
|
+
protocol: [Explicit host documents](protocols.md#explicit-host-documents).
|
|
1261
|
+
|
|
1262
|
+
### Example
|
|
1263
|
+
|
|
1264
|
+
A dragon observatory keeps its control page at `shell/home.html`, settings at
|
|
1265
|
+
`settings.html`, and its selected Arcane runtime at `arcane/`. Run this from
|
|
1266
|
+
that document root after materializing the runtime:
|
|
1267
|
+
|
|
1268
|
+
```javascript
|
|
1269
|
+
import {generateDocumentImportMaps} from 'arcane-os';
|
|
1270
|
+
|
|
1271
|
+
const controller = new AbortController();
|
|
1272
|
+
|
|
1273
|
+
function reportImportMapEvent(event) {
|
|
1274
|
+
console.log(event);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const result = await generateDocumentImportMaps(
|
|
1278
|
+
{
|
|
1279
|
+
documentRoot: process.cwd(),
|
|
1280
|
+
documents: ['shell/home.html', 'settings.html'],
|
|
1281
|
+
signal: controller.signal,
|
|
1282
|
+
onEvent: reportImportMapEvent
|
|
1283
|
+
}
|
|
1284
|
+
);
|
|
1285
|
+
|
|
1286
|
+
for (const document of result.documents) {
|
|
1287
|
+
console.log(document.path, document.imports['arcane/AI']);
|
|
1288
|
+
}
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
Change the explicit document list to update another host page. If either page
|
|
1292
|
+
uses `<base href="/control/">`, add
|
|
1293
|
+
`deploymentUrl: 'https://example.com/control/'` to the options. Both pages keep
|
|
1294
|
+
their authored layout and receive map URLs appropriate to their own base.
|
|
1295
|
+
|
|
1140
1296
|
## getSdkBrowserRuntimeRoot()
|
|
1141
1297
|
|
|
1142
1298
|
### Overview
|
|
@@ -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/import-map.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {lstat,mkdir,readFile as readFileFromDisk,readdir,realpath,writeFile} fro
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {pathToFileURL} from 'node:url';
|
|
5
5
|
import {SDK_VERSION} from './constants.mjs';
|
|
6
|
+
import {listRuntimeFiles} from './runtime.mjs';
|
|
6
7
|
|
|
7
8
|
const is = new Is(false);
|
|
8
9
|
|
|
@@ -39,7 +40,7 @@ function fail(message,code='ARCANE_IMPORT_MAP_INVALID'){
|
|
|
39
40
|
function throwIfAborted(signal){
|
|
40
41
|
if(!signal?.aborted)return;
|
|
41
42
|
const error=signal.reason instanceof Error?signal.reason:new Error('Operation cancelled.');
|
|
42
|
-
error.code
|
|
43
|
+
if(error.code===undefined)error.code='ARCANE_CANCELLED';
|
|
43
44
|
throw error;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -1296,9 +1297,16 @@ function validateInventory(files){
|
|
|
1296
1297
|
return exact;
|
|
1297
1298
|
}
|
|
1298
1299
|
|
|
1299
|
-
|
|
1300
|
+
function encodedUrlPath(relative) {
|
|
1301
|
+
return relative.split('/').map(encodeURIComponent).join('/');
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
export async function buildImportMap({files,signal,version=SDK_VERSION,encodePaths=false}={}){
|
|
1300
1305
|
throwIfAborted(signal);
|
|
1301
1306
|
const inventory=validateInventory(files);
|
|
1307
|
+
function runtimeTarget(relative) {
|
|
1308
|
+
return `./arcane/${encodePaths ? encodedUrlPath(relative) : relative}`;
|
|
1309
|
+
}
|
|
1302
1310
|
const modules=[...inventory]
|
|
1303
1311
|
.filter(relative=>relative.startsWith('modules/')
|
|
1304
1312
|
&&!relative.slice('modules/'.length).includes('/')
|
|
@@ -1308,7 +1316,7 @@ export async function buildImportMap({files,signal,version=SDK_VERSION}={}){
|
|
|
1308
1316
|
for(const relative of modules){
|
|
1309
1317
|
throwIfAborted(signal);
|
|
1310
1318
|
const name=path.posix.basename(relative).replace(JAVASCRIPT_EXTENSION,'');
|
|
1311
|
-
registerSpecifier(namedRegistry,`arcane/${name}
|
|
1319
|
+
registerSpecifier(namedRegistry,`arcane/${name}`,runtimeTarget(relative));
|
|
1312
1320
|
}
|
|
1313
1321
|
const entities=[...inventory].filter(relative=>relative.startsWith('entities/')
|
|
1314
1322
|
&&!relative.slice('entities/'.length).includes('/')
|
|
@@ -1316,7 +1324,7 @@ export async function buildImportMap({files,signal,version=SDK_VERSION}={}){
|
|
|
1316
1324
|
for(const relative of entities){
|
|
1317
1325
|
throwIfAborted(signal);
|
|
1318
1326
|
const name=path.posix.basename(relative).replace(JAVASCRIPT_EXTENSION,'');
|
|
1319
|
-
registerSpecifier(namedRegistry,`arcane/entities/${name}
|
|
1327
|
+
registerSpecifier(namedRegistry,`arcane/entities/${name}`,runtimeTarget(relative));
|
|
1320
1328
|
}
|
|
1321
1329
|
for(const [specifier,relative] of STATIC_RUNTIME_PACKAGE_IMPORTS){
|
|
1322
1330
|
if(inventory.has(relative)){
|
|
@@ -1363,7 +1371,8 @@ export async function buildImportMap({files,signal,version=SDK_VERSION}={}){
|
|
|
1363
1371
|
}
|
|
1364
1372
|
for(const relative of [...inventory].sort(compareText)){
|
|
1365
1373
|
if(JAVASCRIPT_EXTENSION.test(relative)){
|
|
1366
|
-
|
|
1374
|
+
const target=runtimeTarget(relative);
|
|
1375
|
+
registerSpecifier(namedRegistry,target,target);
|
|
1367
1376
|
}
|
|
1368
1377
|
}
|
|
1369
1378
|
const imports={};
|
|
@@ -2062,8 +2071,7 @@ function removeManagedBlocks(html,blocks){
|
|
|
2062
2071
|
return result;
|
|
2063
2072
|
}
|
|
2064
2073
|
|
|
2065
|
-
function firstModulePosition(html){
|
|
2066
|
-
const structure=scanHtmlStructure(html);
|
|
2074
|
+
function firstModulePosition(html,structure=scanHtmlStructure(html)){
|
|
2067
2075
|
let first=-1;
|
|
2068
2076
|
for(const script of structure.scripts){
|
|
2069
2077
|
const attributes=parseTagAttributes(script.open);
|
|
@@ -2434,6 +2442,312 @@ export async function readApplicationTestImportMapContext({
|
|
|
2434
2442
|
});
|
|
2435
2443
|
}
|
|
2436
2444
|
|
|
2445
|
+
function documentImportMapStructure(html) {
|
|
2446
|
+
const structure = scanHtmlStructure(html);
|
|
2447
|
+
const managed = [];
|
|
2448
|
+
const executableTypes = new Set(
|
|
2449
|
+
[
|
|
2450
|
+
'', 'module', 'application/ecmascript', 'application/javascript',
|
|
2451
|
+
'application/x-ecmascript', 'application/x-javascript',
|
|
2452
|
+
'text/ecmascript', 'text/javascript', 'text/javascript1.0',
|
|
2453
|
+
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3',
|
|
2454
|
+
'text/javascript1.4', 'text/javascript1.5', 'text/jscript',
|
|
2455
|
+
'text/livescript', 'text/x-ecmascript', 'text/x-javascript'
|
|
2456
|
+
]
|
|
2457
|
+
);
|
|
2458
|
+
let firstLoad = firstModulePosition(html, structure);
|
|
2459
|
+
let base = null;
|
|
2460
|
+
for (const element of structure.bases) {
|
|
2461
|
+
const attributes = parseTagAttributes(element.open);
|
|
2462
|
+
if (!attributes.has('href')) continue;
|
|
2463
|
+
base = {
|
|
2464
|
+
...element,
|
|
2465
|
+
href:structuralAttribute(attributes, 'href', 'base')
|
|
2466
|
+
};
|
|
2467
|
+
break;
|
|
2468
|
+
}
|
|
2469
|
+
for (const script of structure.scripts) {
|
|
2470
|
+
const attributes = parseTagAttributes(script.open);
|
|
2471
|
+
const type = scriptType(attributes);
|
|
2472
|
+
if (type === 'importmap' && attributes.has(MANAGED_IMPORT_MAP_ATTRIBUTE)) {
|
|
2473
|
+
if (!script.closed) fail('Document contains an unterminated SDK import-map script.');
|
|
2474
|
+
managed.push(script);
|
|
2475
|
+
}
|
|
2476
|
+
if (executableTypes.has(type) && (firstLoad < 0 || script.start < firstLoad)) {
|
|
2477
|
+
firstLoad = script.start;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
if (base && firstLoad >= 0 && base.end > firstLoad) {
|
|
2481
|
+
fail('Document base must precede executable scripts and modulepreloads.');
|
|
2482
|
+
}
|
|
2483
|
+
return {
|
|
2484
|
+
structure,
|
|
2485
|
+
managed,
|
|
2486
|
+
firstLoad,
|
|
2487
|
+
base
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
function documentRelativeUrl(target, base) {
|
|
2492
|
+
if (target.protocol !== base.protocol || target.host !== base.host) return target.href;
|
|
2493
|
+
const directory = new URL('.', base);
|
|
2494
|
+
const baseSegments = directory.pathname.split('/');
|
|
2495
|
+
baseSegments.pop();
|
|
2496
|
+
const targetSegments = target.pathname.split('/');
|
|
2497
|
+
let shared = 0;
|
|
2498
|
+
while (shared < baseSegments.length && shared < targetSegments.length - 1
|
|
2499
|
+
&& baseSegments[shared] === targetSegments[shared]) {
|
|
2500
|
+
shared += 1;
|
|
2501
|
+
}
|
|
2502
|
+
// URL paths retain empty segments; filesystem relative() would collapse them.
|
|
2503
|
+
const relative = [
|
|
2504
|
+
...new Array(baseSegments.length - shared).fill('..'),
|
|
2505
|
+
...targetSegments.slice(shared)
|
|
2506
|
+
].join('/');
|
|
2507
|
+
const pathname = relative.startsWith('./') || relative.startsWith('../')
|
|
2508
|
+
? relative : `./${relative}`;
|
|
2509
|
+
return `${pathname}${target.search}${target.hash}`;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function documentImports(imports, context, baseHref, relative) {
|
|
2513
|
+
const documentUrl = new URL(encodedUrlPath(relative), context.deployment);
|
|
2514
|
+
// Match URL parser normalization only for resolving the base; preserve authored HTML.
|
|
2515
|
+
const baseAddress = (baseHref ?? '').replace(/[\t\r\n]/gu, '').replace(
|
|
2516
|
+
/^[\u0000-\u0020]+|[\u0000-\u0020]+$/gu,
|
|
2517
|
+
''
|
|
2518
|
+
);
|
|
2519
|
+
if (!context.explicitDeployment && /^(?:[\\/]|[A-Za-z][A-Za-z0-9+.-]*:)/u.test(baseAddress)) {
|
|
2520
|
+
throw new TypeError('An absolute or root-relative document base requires deploymentUrl.');
|
|
2521
|
+
}
|
|
2522
|
+
if (!context.explicitDeployment) {
|
|
2523
|
+
let depth = relative.split('/').length - 1;
|
|
2524
|
+
const basePath = baseAddress.split(/[?#]/u)[0].replaceAll('\\', '/');
|
|
2525
|
+
for (const part of basePath.split('/')) {
|
|
2526
|
+
const segment = part.replace(/%2e/giu, '.');
|
|
2527
|
+
if (segment === '..') {
|
|
2528
|
+
if (depth === 0) {
|
|
2529
|
+
throw new TypeError('A document base traversing above documentRoot requires deploymentUrl.');
|
|
2530
|
+
}
|
|
2531
|
+
depth -= 1;
|
|
2532
|
+
} else if (segment !== '.') {
|
|
2533
|
+
depth += 1;
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
const base = baseHref === undefined ? documentUrl : new URL(baseHref, documentUrl);
|
|
2538
|
+
function rebaseUrl(value) {
|
|
2539
|
+
if (value.startsWith('./arcane/')) {
|
|
2540
|
+
return documentRelativeUrl(new URL(value.substring('./arcane/'.length), context.runtime), base);
|
|
2541
|
+
}
|
|
2542
|
+
if (value.startsWith('./node_modules/')) {
|
|
2543
|
+
return documentRelativeUrl(new URL(value, context.deployment), base);
|
|
2544
|
+
}
|
|
2545
|
+
return value;
|
|
2546
|
+
}
|
|
2547
|
+
const rebased = {};
|
|
2548
|
+
for (const [specifier, target] of Object.entries(imports)) {
|
|
2549
|
+
rebased[rebaseUrl(specifier)] = rebaseUrl(target);
|
|
2550
|
+
}
|
|
2551
|
+
return rebased;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
function renderDocumentImportMap(html, imports, state) {
|
|
2555
|
+
const {structure, managed, firstLoad, base} = state;
|
|
2556
|
+
const newline = html.includes('\r\n') ? '\r\n' : '\n';
|
|
2557
|
+
const json = JSON.stringify({imports}, null, 2).replaceAll('<', '\\u003c');
|
|
2558
|
+
const block = `<script type="importmap" ${MANAGED_IMPORT_MAP_ATTRIBUTE}>${newline}${json}${newline}</script>`;
|
|
2559
|
+
const firstManaged = managed[0];
|
|
2560
|
+
const replaceInPlace = firstManaged
|
|
2561
|
+
&& (!base || firstManaged.start >= base.end)
|
|
2562
|
+
&& (firstLoad < 0 || firstManaged.start < firstLoad);
|
|
2563
|
+
const edits = managed.map(
|
|
2564
|
+
function replaceSdkMap(script, index) {
|
|
2565
|
+
return {
|
|
2566
|
+
start:script.start,
|
|
2567
|
+
end:script.end,
|
|
2568
|
+
value:replaceInPlace && index === 0 ? block : ''
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
);
|
|
2572
|
+
if (!replaceInPlace) {
|
|
2573
|
+
const candidate = firstLoad >= 0 ? firstLoad
|
|
2574
|
+
: structure.headClose >= 0 ? structure.headClose
|
|
2575
|
+
: structure.bodyClose >= 0 ? structure.bodyClose : html.length;
|
|
2576
|
+
const insertion = Math.max(candidate, base?.end ?? 0);
|
|
2577
|
+
edits.push(
|
|
2578
|
+
{
|
|
2579
|
+
start:insertion,
|
|
2580
|
+
end:insertion,
|
|
2581
|
+
value:block
|
|
2582
|
+
}
|
|
2583
|
+
);
|
|
2584
|
+
}
|
|
2585
|
+
// Exact script ranges are SDK-owned; surrounding whitespace and author maps are not.
|
|
2586
|
+
return applyReferenceEdits(html, edits);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
/** Generate SDK maps for explicitly selected documents without imposing an app layout. */
|
|
2590
|
+
export async function generateDocumentImportMaps({
|
|
2591
|
+
documentRoot,
|
|
2592
|
+
runtimeRoot,
|
|
2593
|
+
documents,
|
|
2594
|
+
version = SDK_VERSION,
|
|
2595
|
+
deploymentUrl,
|
|
2596
|
+
signal,
|
|
2597
|
+
onEvent
|
|
2598
|
+
} = {}) {
|
|
2599
|
+
if (!is.string(documentRoot) || !documentRoot.trim()) {
|
|
2600
|
+
throw new TypeError('generateDocumentImportMaps documentRoot must be a nonempty string.');
|
|
2601
|
+
}
|
|
2602
|
+
if (!is.array(documents) || documents.length === 0) {
|
|
2603
|
+
throw new TypeError('generateDocumentImportMaps documents must be a nonempty array of relative paths.');
|
|
2604
|
+
}
|
|
2605
|
+
throwIfAborted(signal);
|
|
2606
|
+
const root = path.resolve(documentRoot);
|
|
2607
|
+
const runtime = path.resolve(runtimeRoot ?? path.join(root, 'arcane'));
|
|
2608
|
+
const selected = [...new Set(documents.map(
|
|
2609
|
+
function documentPath(value) {
|
|
2610
|
+
return safeRelativePath(value, 'document path');
|
|
2611
|
+
}
|
|
2612
|
+
))];
|
|
2613
|
+
const deployment = deploymentUrl === undefined
|
|
2614
|
+
? pathToFileURL(`${root}${path.sep}`) : new URL(deploymentUrl);
|
|
2615
|
+
if (!deployment.pathname.endsWith('/')) {
|
|
2616
|
+
throw new TypeError('deploymentUrl must name a directory URL ending in /.');
|
|
2617
|
+
}
|
|
2618
|
+
const relativeRuntime = path.relative(root, runtime).split(path.sep).join('/');
|
|
2619
|
+
if (path.isAbsolute(path.relative(root, runtime))) {
|
|
2620
|
+
throw new TypeError('documentRoot and runtimeRoot must share a filesystem volume.');
|
|
2621
|
+
}
|
|
2622
|
+
const context = {
|
|
2623
|
+
deployment,
|
|
2624
|
+
runtime:new URL(relativeRuntime ? `${encodedUrlPath(relativeRuntime)}/` : './', deployment),
|
|
2625
|
+
explicitDeployment:deploymentUrl !== undefined
|
|
2626
|
+
};
|
|
2627
|
+
const documentPaths = selected.map(
|
|
2628
|
+
function resolveDocumentPath(relative) {
|
|
2629
|
+
return path.join(root, ...relative.split('/'));
|
|
2630
|
+
}
|
|
2631
|
+
);
|
|
2632
|
+
let eventError = await emit(
|
|
2633
|
+
onEvent,
|
|
2634
|
+
{
|
|
2635
|
+
type:'import-map.documents.started',
|
|
2636
|
+
documentRoot:root,
|
|
2637
|
+
runtimeRoot:runtime,
|
|
2638
|
+
documentPaths
|
|
2639
|
+
}
|
|
2640
|
+
);
|
|
2641
|
+
const documentStates = new Array(selected.length);
|
|
2642
|
+
const pending = selected.entries();
|
|
2643
|
+
async function readDocuments() {
|
|
2644
|
+
for (const [index, relative] of pending) {
|
|
2645
|
+
throwIfAborted(signal);
|
|
2646
|
+
const html = await readFileFromDisk(documentPaths[index], 'utf8');
|
|
2647
|
+
throwIfAborted(signal);
|
|
2648
|
+
documentStates[index] = {
|
|
2649
|
+
path:relative,
|
|
2650
|
+
filePath:documentPaths[index],
|
|
2651
|
+
html,
|
|
2652
|
+
state:documentImportMapStructure(html)
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
const readers = Array.from(
|
|
2657
|
+
{length:Math.min(4, selected.length)},
|
|
2658
|
+
readDocuments
|
|
2659
|
+
);
|
|
2660
|
+
const outcomes = await Promise.allSettled(
|
|
2661
|
+
[
|
|
2662
|
+
listRuntimeFiles(
|
|
2663
|
+
{
|
|
2664
|
+
runtimeRoot:runtime,
|
|
2665
|
+
signal
|
|
2666
|
+
}
|
|
2667
|
+
),
|
|
2668
|
+
...readers
|
|
2669
|
+
]
|
|
2670
|
+
);
|
|
2671
|
+
throwIfAborted(signal);
|
|
2672
|
+
const errors = outcomes.filter(
|
|
2673
|
+
function failedRead(outcome) {
|
|
2674
|
+
return outcome.status === 'rejected';
|
|
2675
|
+
}
|
|
2676
|
+
).map(
|
|
2677
|
+
function readError(outcome) {
|
|
2678
|
+
return outcome.reason;
|
|
2679
|
+
}
|
|
2680
|
+
);
|
|
2681
|
+
if (errors.length === 1) throw errors[0];
|
|
2682
|
+
if (errors.length > 1) throw new AggregateError(errors, 'Document import-map preparation failed.');
|
|
2683
|
+
const built = await buildImportMap(
|
|
2684
|
+
{
|
|
2685
|
+
files:outcomes[0].value,
|
|
2686
|
+
signal,
|
|
2687
|
+
version,
|
|
2688
|
+
encodePaths:true
|
|
2689
|
+
}
|
|
2690
|
+
);
|
|
2691
|
+
const rendered = documentStates.map(
|
|
2692
|
+
function renderSelectedDocument(document) {
|
|
2693
|
+
throwIfAborted(signal);
|
|
2694
|
+
const imports = documentImports(built.imports, context, document.state.base?.href, document.path);
|
|
2695
|
+
return {
|
|
2696
|
+
path:document.path,
|
|
2697
|
+
filePath:document.filePath,
|
|
2698
|
+
imports,
|
|
2699
|
+
html:renderDocumentImportMap(document.html, imports, document.state)
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
);
|
|
2703
|
+
const paths = [];
|
|
2704
|
+
for (const document of rendered) {
|
|
2705
|
+
throwIfAborted(signal);
|
|
2706
|
+
await writeFile(document.filePath, document.html, 'utf8');
|
|
2707
|
+
paths.push(document.filePath);
|
|
2708
|
+
const deliveryError = await emit(
|
|
2709
|
+
onEvent,
|
|
2710
|
+
{
|
|
2711
|
+
type:'import-map.write.progress',
|
|
2712
|
+
paths:[...paths]
|
|
2713
|
+
}
|
|
2714
|
+
);
|
|
2715
|
+
eventError ??= deliveryError;
|
|
2716
|
+
}
|
|
2717
|
+
const result = {
|
|
2718
|
+
documentRoot:root,
|
|
2719
|
+
runtimeRoot:runtime,
|
|
2720
|
+
documentPaths,
|
|
2721
|
+
documentCount:documentPaths.length,
|
|
2722
|
+
documents:rendered.map(
|
|
2723
|
+
function documentResult(document) {
|
|
2724
|
+
return {
|
|
2725
|
+
path:document.path,
|
|
2726
|
+
filePath:document.filePath,
|
|
2727
|
+
imports:document.imports
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
),
|
|
2731
|
+
committed:true
|
|
2732
|
+
};
|
|
2733
|
+
const completedError = await emit(
|
|
2734
|
+
onEvent,
|
|
2735
|
+
{
|
|
2736
|
+
type:'import-map.documents.completed',
|
|
2737
|
+
...result
|
|
2738
|
+
}
|
|
2739
|
+
);
|
|
2740
|
+
eventError ??= completedError;
|
|
2741
|
+
if (eventError) {
|
|
2742
|
+
result.eventDelivery = {
|
|
2743
|
+
status:'degraded',
|
|
2744
|
+
errorCode:'ARCANE_EVENT_DELIVERY_FAILED',
|
|
2745
|
+
message:String(eventError?.message ?? eventError)
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
return result;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2437
2751
|
async function generateImportMapUnlocked({
|
|
2438
2752
|
workspaceRoot,
|
|
2439
2753
|
appId,
|
package/src/index.mjs
CHANGED
|
@@ -135,6 +135,7 @@ export {
|
|
|
135
135
|
readSdkBrowserRuntimeFile
|
|
136
136
|
} from './sdk-browser-runtime.mjs';
|
|
137
137
|
export {materializeWorkspaceRuntimeContent} from './workspace-runtime.mjs';
|
|
138
|
+
export {generateDocumentImportMaps} from './import-map.mjs';
|
|
138
139
|
export {
|
|
139
140
|
discoverApps,
|
|
140
141
|
inspectWorkspaceProfile,
|
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/runtime.mjs
CHANGED
|
@@ -17,7 +17,7 @@ function fail(message,code='ARCANE_RUNTIME_INVALID'){
|
|
|
17
17
|
function throwIfAborted(signal){
|
|
18
18
|
if(!signal?.aborted)return;
|
|
19
19
|
const error=signal.reason instanceof Error?signal.reason:new Error('Operation cancelled.');
|
|
20
|
-
error.code
|
|
20
|
+
if(error.code===undefined)error.code='ARCANE_CANCELLED';
|
|
21
21
|
throw error;
|
|
22
22
|
}
|
|
23
23
|
|