arcane-os 0.54.1 → 0.55.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 +31 -0
- package/README.md +31 -26
- package/browser-runtime/core/client.mjs +491 -0
- package/browser-runtime/core/contracts.mjs +84 -0
- package/docs/architecture.md +31 -23
- package/docs/compatibility.md +10 -1
- package/docs/platform-targets.md +20 -11
- package/docs/reference/README.md +8 -6
- package/docs/reference/availability-and-normalization.md +4 -2
- package/docs/reference/behavioral-testing.md +7 -6
- package/docs/reference/cli.md +12 -4
- package/docs/reference/core-client.md +120 -0
- package/docs/reference/core-native-packaging.md +149 -0
- package/docs/reference/core-runtime.md +175 -0
- package/docs/reference/inventory/package-api.json +257 -5
- package/docs/reference/protocols.md +16 -1
- package/docs/reference/sdk-api.md +772 -15
- package/package.json +10 -3
- package/schemas/native-build-plan.schema.json +8 -0
- package/src/cli/main.mjs +8 -6
- package/src/core/host.mjs +23 -0
- package/src/core/runtime.mjs +333 -0
- package/src/core/stdio.mjs +209 -0
- package/src/core-classic-source.mjs +43 -0
- package/src/import-map.mjs +62 -0
- package/src/native/portable-layout.mjs +184 -0
- package/src/native/portable-provider.mjs +211 -0
- package/src/native-plan.mjs +10 -4
- package/src/native-provider-loader.mjs +15 -4
- package/src/packager/core.mjs +47 -18
- package/src/targets/index.mjs +3 -1
- package/src/toolchain.mjs +20 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcane-os",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
"./targets": "./src/targets/index.mjs",
|
|
49
49
|
"./native": "./src/native-plan.mjs",
|
|
50
50
|
"./native-provider": "./src/native-provider-loader.mjs",
|
|
51
|
+
"./native/portable-provider": "./src/native/portable-provider.mjs",
|
|
52
|
+
"./core/client": "./browser-runtime/core/client.mjs",
|
|
53
|
+
"./core/contracts": "./browser-runtime/core/contracts.mjs",
|
|
54
|
+
"./core/classic-source": "./src/core-classic-source.mjs",
|
|
55
|
+
"./core/runtime": "./src/core/runtime.mjs",
|
|
56
|
+
"./core/stdio": "./src/core/stdio.mjs",
|
|
57
|
+
"./core/host": "./src/core/host.mjs",
|
|
51
58
|
"./integrated-provider": "./src/integrated-provider-loader.mjs",
|
|
52
59
|
"./packager": "./src/packager/core.mjs",
|
|
53
60
|
"./release-bundle": "./src/release-bundle.mjs",
|
|
@@ -89,8 +96,8 @@
|
|
|
89
96
|
"test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
|
|
90
97
|
"test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
|
|
91
98
|
"test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
|
|
92
|
-
"test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
|
|
93
|
-
"test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
|
|
99
|
+
"test:functional": "node ./bin/arcane-test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-speech-providers.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-host.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
|
|
100
|
+
"test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-plan.test.mjs test/native-portable-provider.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
|
|
94
101
|
"test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/html-import-packaged-script.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
|
|
95
102
|
"check": "node tools/check-source.mjs && npm test",
|
|
96
103
|
"check:release": "node tools/check-source.mjs --package-only && npm run test:release",
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
"toolchainRoot": { "type": "string", "minLength": 1 },
|
|
24
24
|
"toolchain": { "type": "object" },
|
|
25
25
|
"appDescriptor": { "type": "object" },
|
|
26
|
+
"selectedSdk": {
|
|
27
|
+
"description": "Actual selected app SDK installation used to complete a partial native package projection without replacing selected files.",
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"packageRoot": { "type": "string" },
|
|
31
|
+
"packageSource": { "type": "string" }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
26
34
|
"application": { "$ref": "#/$defs/releaseSelection" },
|
|
27
35
|
"dependencies": {
|
|
28
36
|
"type": "array",
|
package/src/cli/main.mjs
CHANGED
|
@@ -75,8 +75,8 @@ Usage:
|
|
|
75
75
|
${CLI_NAME} verify [--app <id>]
|
|
76
76
|
${CLI_NAME} bundle [--app <id>] [--artifact <file>${APP_BUNDLE_EXTENSION}] [--overwrite]
|
|
77
77
|
${CLI_NAME} verify-bundle <file${APP_BUNDLE_EXTENSION}>
|
|
78
|
-
${CLI_NAME} native-doctor --target <native-target> --arcane-root <directory>
|
|
79
|
-
${CLI_NAME} native-prepare --target <native-target> --arcane-root <directory>
|
|
78
|
+
${CLI_NAME} native-doctor --target <native-target> [--arcane-root <directory>]
|
|
79
|
+
${CLI_NAME} native-prepare --target <native-target> [--arcane-root <directory>]
|
|
80
80
|
${CLI_NAME} build --target <target> [--arcane-root <directory>] [--output-root <directory>] [--format <format>] [--signing <mode>]
|
|
81
81
|
${CLI_NAME} run [--target <target>] [--app <id>] [--cert <pem>] [--key <pem>] [--port 8000] [--http-port 0] [--arcane-root <directory>] [--output-root <directory>] [--format <format>] [--signing <mode>]
|
|
82
82
|
${CLI_NAME} update-check
|
|
@@ -105,9 +105,10 @@ Global:
|
|
|
105
105
|
--version Show the SDK version.
|
|
106
106
|
|
|
107
107
|
The npm package is ${SDK_NAME}. Both the ${CLI_NAME} and arcane-os executables
|
|
108
|
-
invoke this same headless toolchain.
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
invoke this same headless toolchain. Portable uses the installed SDK provider
|
|
109
|
+
by default and produces a non-executable app payload. An explicit --arcane-root
|
|
110
|
+
selects the Arcane OS provider instead. Windows EXE, Linux DEB, and Android APK
|
|
111
|
+
targets still require an explicit --arcane-root and their platform toolchains.`;
|
|
111
112
|
|
|
112
113
|
function usage(message){
|
|
113
114
|
throw new ArcaneError(ERROR_CODES.usage,message);
|
|
@@ -810,7 +811,7 @@ async function pairNativeProvider(command,options,loadProvider,{signal,onEvent}=
|
|
|
810
811
|
usage(`${command} requires one native target.`);
|
|
811
812
|
}
|
|
812
813
|
const targetRequest=createNativeTargetRequest(options);
|
|
813
|
-
if(!options.arcaneRoot){
|
|
814
|
+
if(!options.arcaneRoot&&options.target!=='portable'){
|
|
814
815
|
usage(`${command} for target ${options.target} requires --arcane-root <directory>.`);
|
|
815
816
|
}
|
|
816
817
|
const loaded=await loadProvider({
|
|
@@ -823,6 +824,7 @@ async function pairNativeProvider(command,options,loadProvider,{signal,onEvent}=
|
|
|
823
824
|
...options,
|
|
824
825
|
nativeBuilder:loaded.nativeBuilder,
|
|
825
826
|
toolchainRoot:loaded.toolchainRoot,
|
|
827
|
+
providerSource:loaded.providerSource,
|
|
826
828
|
targetRequest
|
|
827
829
|
};
|
|
828
830
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {createCoreRuntime} from './runtime.mjs';
|
|
2
|
+
import {startCoreStdio} from './stdio.mjs';
|
|
3
|
+
|
|
4
|
+
/** Compose app services with the shared dispatcher and draining transport. */
|
|
5
|
+
export function startCoreHost({application, version, services = [], input, output, onError, signal} = {}) {
|
|
6
|
+
const runtime = createCoreRuntime({application, version, services});
|
|
7
|
+
const host = startCoreStdio({runtime, input, output, onError});
|
|
8
|
+
if (!signal) return host;
|
|
9
|
+
|
|
10
|
+
function closeHostOnAbort() {
|
|
11
|
+
// The transport owns cancellation, accepted-service work and final output.
|
|
12
|
+
host.close();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function releaseAbortListener() {
|
|
16
|
+
signal.removeEventListener('abort', closeHostOnAbort);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
signal.addEventListener('abort', closeHostOnAbort, {once: true});
|
|
20
|
+
host.closed.then(releaseAbortListener, releaseAbortListener);
|
|
21
|
+
if (signal.aborted) closeHostOnAbort();
|
|
22
|
+
return host;
|
|
23
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import {createArcaneEventSource} from '../event-manager.mjs';
|
|
3
|
+
import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
4
|
+
|
|
5
|
+
const is = new Is(false);
|
|
6
|
+
const STATE_EVENT = 'core.runtime.state';
|
|
7
|
+
const FRAME_EVENT = 'core.runtime.frame';
|
|
8
|
+
|
|
9
|
+
function failure(code, message) {
|
|
10
|
+
return new CoreError({code, message});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireFunction(value, name) {
|
|
14
|
+
if (!is.function(value)) throw new TypeError(`${name} must be a function.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function aborted(controller) {
|
|
18
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** App-neutral dispatch. Hosts own services, privileges and process lifetime. */
|
|
22
|
+
class CoreRuntime {
|
|
23
|
+
#application;
|
|
24
|
+
#version;
|
|
25
|
+
#events;
|
|
26
|
+
#state = 'created';
|
|
27
|
+
#services = new Map();
|
|
28
|
+
#methods = new Map();
|
|
29
|
+
#requests = new Map();
|
|
30
|
+
#responses = new Set();
|
|
31
|
+
#publications = [];
|
|
32
|
+
#publishing = false;
|
|
33
|
+
#closing = null;
|
|
34
|
+
|
|
35
|
+
constructor({application, version, services = []} = {}) {
|
|
36
|
+
this.#application = application;
|
|
37
|
+
this.#version = version;
|
|
38
|
+
this.#events = createArcaneEventSource(
|
|
39
|
+
this,
|
|
40
|
+
{source: 'core-runtime', eventTypes: [STATE_EVENT, FRAME_EVENT]}
|
|
41
|
+
);
|
|
42
|
+
for (const service of services) this.registerService(service);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
current() {
|
|
46
|
+
return {
|
|
47
|
+
state: this.#state,
|
|
48
|
+
application: this.#application,
|
|
49
|
+
version: this.#version,
|
|
50
|
+
services: [...this.#services.values()].map(
|
|
51
|
+
function serviceState(service) {
|
|
52
|
+
return {name: service.name, state: service.state, error: service.error};
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
activeRequests: [...this.#requests.values()].map(
|
|
56
|
+
function requestState(request) {
|
|
57
|
+
return {id: request.id, method: request.method, lifetime: request.lifetime};
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
subscribe(listener, {emitCurrent = true, signal} = {}) {
|
|
64
|
+
requireFunction(listener, 'listener');
|
|
65
|
+
function deliverState(occurrence) {
|
|
66
|
+
listener(occurrence.detail);
|
|
67
|
+
}
|
|
68
|
+
const unsubscribe = this.#events.on(STATE_EVENT, deliverState, {signal});
|
|
69
|
+
try {
|
|
70
|
+
if (emitCurrent && !signal?.aborted) listener(this.current());
|
|
71
|
+
} catch (error) {
|
|
72
|
+
unsubscribe();
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
return unsubscribe;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onFrame(listener, {signal} = {}) {
|
|
79
|
+
requireFunction(listener, 'listener');
|
|
80
|
+
function deliverFrame(occurrence) {
|
|
81
|
+
listener(occurrence.detail);
|
|
82
|
+
}
|
|
83
|
+
return this.#events.on(FRAME_EVENT, deliverFrame, {signal});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#publish() {
|
|
87
|
+
this.#publications.push(this.current());
|
|
88
|
+
if (this.#publishing) return;
|
|
89
|
+
this.#publishing = true;
|
|
90
|
+
try {
|
|
91
|
+
// A synchronous subscriber can change the lifecycle. Finish each
|
|
92
|
+
// state publication before delivering its reentrant successor.
|
|
93
|
+
while (this.#publications.length) {
|
|
94
|
+
const state = this.#publications.shift();
|
|
95
|
+
this.#events.dispatch(STATE_EVENT, state);
|
|
96
|
+
this.emit('core.state', state);
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
this.#publishing = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
emit(event, data) {
|
|
104
|
+
const frame = {protocol: CORE_PROTOCOL, type: 'event', event, data, time: new Date().toISOString()};
|
|
105
|
+
this.#events.dispatch(FRAME_EVENT, frame);
|
|
106
|
+
return frame;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
registerService(definition) {
|
|
110
|
+
if (this.#closing) throw failure('CORE_CLOSING', 'Core is closing.');
|
|
111
|
+
const {name, methods = {}, start, drain, dispose} = definition;
|
|
112
|
+
if (!is.string(name) || !name) throw new TypeError('Service name must be a nonempty string.');
|
|
113
|
+
if (this.#services.has(name)) throw failure('CORE_SERVICE_EXISTS', `Service ${name} is already registered.`);
|
|
114
|
+
for (const [hook, value] of Object.entries({start, drain, dispose})) {
|
|
115
|
+
if (value !== undefined) requireFunction(value, `Service ${hook}`);
|
|
116
|
+
}
|
|
117
|
+
const registrations = [];
|
|
118
|
+
for (const [method, value] of Object.entries(methods)) {
|
|
119
|
+
if (this.#methods.has(method) || ['system.ping', 'app.current', 'version.current'].includes(method)) {
|
|
120
|
+
throw failure('CORE_METHOD_EXISTS', `Method ${method} is already registered.`);
|
|
121
|
+
}
|
|
122
|
+
const handle = is.function(value) ? value : value.handle;
|
|
123
|
+
const lifetime = is.function(value) ? 'request' : value.lifetime ?? 'request';
|
|
124
|
+
requireFunction(handle, `Method ${method}`);
|
|
125
|
+
if (lifetime !== 'request' && lifetime !== 'service') {
|
|
126
|
+
throw new TypeError('Method lifetime must be request or service.');
|
|
127
|
+
}
|
|
128
|
+
registrations.push({method, handle, lifetime});
|
|
129
|
+
}
|
|
130
|
+
const service = {name, definition, state: 'registered', error: null, task: null};
|
|
131
|
+
this.#services.set(name, service);
|
|
132
|
+
for (const registration of registrations) this.#methods.set(registration.method, {...registration, service});
|
|
133
|
+
if (this.#state === 'ready') this.#startService(service);
|
|
134
|
+
const runtime = this;
|
|
135
|
+
return {
|
|
136
|
+
get ready() {
|
|
137
|
+
return runtime.#startService(service);
|
|
138
|
+
},
|
|
139
|
+
current() {
|
|
140
|
+
return {name: service.name, state: service.state, error: service.error};
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#serviceContext(service) {
|
|
146
|
+
const runtime = this;
|
|
147
|
+
return {
|
|
148
|
+
application: this.#application,
|
|
149
|
+
service: service.name,
|
|
150
|
+
emit(event, data) {
|
|
151
|
+
return runtime.emit(event, data);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#serviceState(service, state, error) {
|
|
157
|
+
service.state = state;
|
|
158
|
+
service.error = state === 'failed' || (error !== undefined && error !== null)
|
|
159
|
+
? serializeCoreError(error)
|
|
160
|
+
: null;
|
|
161
|
+
this.emit('core.service.state', {name: service.name, state, error: service.error});
|
|
162
|
+
this.#publish();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#startService(service) {
|
|
166
|
+
if (service.task) return service.task;
|
|
167
|
+
if (this.#closing) return Promise.reject(failure('CORE_CLOSING', 'Core is closing.'));
|
|
168
|
+
const runtime = this;
|
|
169
|
+
// Publish after task assignment so a synchronous state subscriber cannot
|
|
170
|
+
// start the same service twice.
|
|
171
|
+
service.task = Promise.resolve().then(
|
|
172
|
+
async function initializeService() {
|
|
173
|
+
runtime.#serviceState(service, 'starting');
|
|
174
|
+
try {
|
|
175
|
+
await service.definition.start?.(runtime.#serviceContext(service));
|
|
176
|
+
runtime.#serviceState(service, 'ready');
|
|
177
|
+
} catch (error) {
|
|
178
|
+
runtime.#serviceState(service, 'failed', error);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
service.task.catch(function observeServiceStartupFailure() {});
|
|
184
|
+
return service.task;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
start() {
|
|
188
|
+
if (this.#closing) throw failure('CORE_CLOSING', 'Core is closing.');
|
|
189
|
+
if (this.#state === 'ready') return this.current();
|
|
190
|
+
this.#state = 'ready';
|
|
191
|
+
for (const service of this.#services.values()) this.#startService(service);
|
|
192
|
+
this.emit('core.ready', {version: this.#version, app: this.#application});
|
|
193
|
+
this.#publish();
|
|
194
|
+
return this.current();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async handle(frame) {
|
|
198
|
+
if (frame?.protocol !== CORE_PROTOCOL) throw failure('INVALID_RPC_REQUEST', 'Unknown Core protocol.');
|
|
199
|
+
if (frame.type === 'control') {
|
|
200
|
+
if (frame.control === 'request.cancel') return this.#cancel(frame.requestId);
|
|
201
|
+
if (frame.control === 'requests.cancelAll') {
|
|
202
|
+
for (const request of this.#requests.values()) this.#cancel(request.id);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
throw failure('INVALID_RPC_CONTROL', 'Unknown Core control.');
|
|
206
|
+
}
|
|
207
|
+
if (frame.type !== 'request' || !is.string(frame.id) || !frame.id || !is.string(frame.method)) {
|
|
208
|
+
throw failure('INVALID_RPC_REQUEST', 'A Core request requires an id and method.');
|
|
209
|
+
}
|
|
210
|
+
const task = this.#answer(frame);
|
|
211
|
+
this.#responses.add(task);
|
|
212
|
+
try {
|
|
213
|
+
return await task;
|
|
214
|
+
} finally {
|
|
215
|
+
this.#responses.delete(task);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async #answer(frame) {
|
|
220
|
+
let result;
|
|
221
|
+
let error;
|
|
222
|
+
let ok = true;
|
|
223
|
+
try {
|
|
224
|
+
if (this.#state !== 'ready') throw failure('CORE_NOT_READY', 'Core is not accepting requests.');
|
|
225
|
+
if (this.#requests.has(frame.id)) throw failure('RPC_REQUEST_ID_ACTIVE', 'The request id is already active.');
|
|
226
|
+
if (frame.method === 'system.ping') result = {ok: true};
|
|
227
|
+
else if (frame.method === 'version.current') result = this.#version;
|
|
228
|
+
else if (frame.method === 'app.current') result = this.#application;
|
|
229
|
+
else result = await this.#request(frame);
|
|
230
|
+
} catch (caught) {
|
|
231
|
+
ok = false;
|
|
232
|
+
error = caught;
|
|
233
|
+
}
|
|
234
|
+
const response = {
|
|
235
|
+
protocol: CORE_PROTOCOL,
|
|
236
|
+
type: 'response',
|
|
237
|
+
id: frame.id,
|
|
238
|
+
...(ok ? {ok: true, result} : {ok: false, error: serializeCoreError(error)}),
|
|
239
|
+
time: new Date().toISOString()
|
|
240
|
+
};
|
|
241
|
+
this.#events.dispatch(FRAME_EVENT, response);
|
|
242
|
+
return response;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#request(frame) {
|
|
246
|
+
const registration = this.#methods.get(frame.method);
|
|
247
|
+
if (!registration) throw failure('METHOD_NOT_ALLOWED', `Core does not expose ${frame.method}.`);
|
|
248
|
+
const {service, handle, lifetime} = registration;
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const request = {id: frame.id, method: frame.method, lifetime, controller, task: null};
|
|
251
|
+
const runtime = this;
|
|
252
|
+
this.#requests.set(frame.id, request);
|
|
253
|
+
request.task = Promise.resolve().then(
|
|
254
|
+
async function executeCoreRequest() {
|
|
255
|
+
try {
|
|
256
|
+
await runtime.#startService(service);
|
|
257
|
+
aborted(controller);
|
|
258
|
+
const result = await handle.call(
|
|
259
|
+
service.definition,
|
|
260
|
+
frame.parameters,
|
|
261
|
+
{...runtime.#serviceContext(service), requestId: frame.id, signal: controller.signal}
|
|
262
|
+
);
|
|
263
|
+
aborted(controller);
|
|
264
|
+
return result;
|
|
265
|
+
} finally {
|
|
266
|
+
if (runtime.#requests.get(frame.id) === request) runtime.#requests.delete(frame.id);
|
|
267
|
+
runtime.#publish();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
this.#publish();
|
|
272
|
+
return request.task;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
#cancel(id) {
|
|
276
|
+
const request = this.#requests.get(id);
|
|
277
|
+
if (!request || request.lifetime === 'service' || request.controller.signal.aborted) return false;
|
|
278
|
+
const error = failure('REQUEST_ABORTED', 'The Core request was cancelled.');
|
|
279
|
+
error.name = 'AbortError';
|
|
280
|
+
request.controller.abort(error);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
close() {
|
|
285
|
+
if (this.#closing) return this.#closing;
|
|
286
|
+
this.#state = 'draining';
|
|
287
|
+
const runtime = this;
|
|
288
|
+
// A synchronous state listener may call close again. Retain the owner
|
|
289
|
+
// before announcing draining, and never let a renderer cancel saves.
|
|
290
|
+
this.#closing = Promise.resolve().then(
|
|
291
|
+
async function drainCoreRuntime() {
|
|
292
|
+
const failures = [];
|
|
293
|
+
for (const request of runtime.#requests.values()) runtime.#cancel(request.id);
|
|
294
|
+
await Promise.allSettled([...runtime.#responses]);
|
|
295
|
+
await Promise.all(
|
|
296
|
+
[...runtime.#services.values()].map(
|
|
297
|
+
async function closeCoreService(service) {
|
|
298
|
+
const serviceFailures = [];
|
|
299
|
+
if (service.task) {
|
|
300
|
+
try { await service.task; } catch (error) { serviceFailures.push(error); }
|
|
301
|
+
}
|
|
302
|
+
runtime.#serviceState(service, 'draining');
|
|
303
|
+
for (const hook of ['drain', 'dispose']) {
|
|
304
|
+
try {
|
|
305
|
+
await service.definition[hook]?.(runtime.#serviceContext(service));
|
|
306
|
+
} catch (error) {
|
|
307
|
+
serviceFailures.push(error);
|
|
308
|
+
runtime.emit('core.error', serializeCoreError(error));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const error = serviceFailures.length
|
|
312
|
+
? new AggregateError(serviceFailures, `Service ${service.name} shutdown failed.`)
|
|
313
|
+
: null;
|
|
314
|
+
failures.push(...serviceFailures);
|
|
315
|
+
runtime.#serviceState(service, 'closed', error);
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
);
|
|
319
|
+
runtime.#state = 'closed';
|
|
320
|
+
runtime.#publish();
|
|
321
|
+
runtime.#events.dispose();
|
|
322
|
+
if (failures.length) throw new AggregateError(failures, 'Core service shutdown failed.');
|
|
323
|
+
return runtime.current();
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
this.#publish();
|
|
327
|
+
return this.#closing;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function createCoreRuntime(options) {
|
|
332
|
+
return new CoreRuntime(options);
|
|
333
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {finished} from 'node:stream';
|
|
2
|
+
import Is from 'strong-type';
|
|
3
|
+
import {CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
4
|
+
|
|
5
|
+
const is = new Is(false);
|
|
6
|
+
|
|
7
|
+
/** Content-Length is used only for the existing native transport framing. */
|
|
8
|
+
export function encodeCoreFrame(frame) {
|
|
9
|
+
const body = Buffer.from(JSON.stringify(frame), 'utf8');
|
|
10
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createCoreFrameDecoder(onFrame) {
|
|
14
|
+
let header = Buffer.alloc(0);
|
|
15
|
+
let remaining = null;
|
|
16
|
+
let parts = [];
|
|
17
|
+
const utf8 = new TextDecoder('utf-8', {fatal: true});
|
|
18
|
+
|
|
19
|
+
function push(chunk) {
|
|
20
|
+
let input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
21
|
+
while (input.length || remaining === 0) {
|
|
22
|
+
if (remaining === null) {
|
|
23
|
+
header = Buffer.concat([header, input]);
|
|
24
|
+
const marker = header.indexOf('\r\n\r\n');
|
|
25
|
+
if (marker < 0) return;
|
|
26
|
+
const match = header.subarray(0, marker).toString('ascii').match(
|
|
27
|
+
/(?:^|\r\n)Content-Length:\s*(\d+)(?:\r\n|$)/i
|
|
28
|
+
);
|
|
29
|
+
if (!match) throw new CoreError({code: 'IPC_LENGTH_MISSING', message: 'IPC content length is missing.'});
|
|
30
|
+
remaining = Number(match[1]);
|
|
31
|
+
if (!is.safeInteger(remaining) || remaining < 0) {
|
|
32
|
+
throw new CoreError({code: 'IPC_LENGTH_INVALID', message: 'IPC content length is invalid.'});
|
|
33
|
+
}
|
|
34
|
+
input = header.subarray(marker + 4);
|
|
35
|
+
header = Buffer.alloc(0);
|
|
36
|
+
}
|
|
37
|
+
const consumed = Math.min(remaining, input.length);
|
|
38
|
+
if (consumed) parts.push(input.subarray(0, consumed));
|
|
39
|
+
input = input.subarray(consumed);
|
|
40
|
+
remaining -= consumed;
|
|
41
|
+
if (remaining > 0) return;
|
|
42
|
+
const body = Buffer.concat(parts);
|
|
43
|
+
parts = [];
|
|
44
|
+
remaining = null;
|
|
45
|
+
onFrame(JSON.parse(utf8.decode(body)));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function finish() {
|
|
50
|
+
if (header.length || remaining !== null) {
|
|
51
|
+
throw new CoreError({code: 'IPC_FRAME_INCOMPLETE', message: 'IPC ended during a frame.'});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {push, finish};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function reportStdioError(error) {
|
|
59
|
+
console.error('Arcane Core transport failed:', error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Attach an existing runtime; EOF drains accepted service work, never exits. */
|
|
63
|
+
export function startCoreStdio({runtime, input = process.stdin, output = process.stdout, onError = reportStdioError}) {
|
|
64
|
+
let closing = null;
|
|
65
|
+
let writes = Promise.resolve();
|
|
66
|
+
let outputFailed = false;
|
|
67
|
+
let inputCompleted = false;
|
|
68
|
+
let resolveInputCompletion;
|
|
69
|
+
const inputCompletion = new Promise(function observeInputCompletion(resolve) {
|
|
70
|
+
resolveInputCompletion = resolve;
|
|
71
|
+
});
|
|
72
|
+
const pending = new Set();
|
|
73
|
+
const failures = [];
|
|
74
|
+
let resolveClosed;
|
|
75
|
+
let rejectClosed;
|
|
76
|
+
const closed = new Promise(
|
|
77
|
+
function createClosedPromise(resolve, reject) {
|
|
78
|
+
resolveClosed = resolve;
|
|
79
|
+
rejectClosed = reject;
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
// Every background error is reported to onError and retained by closed.
|
|
83
|
+
closed.catch(function observeReportedTransportFailure() {});
|
|
84
|
+
|
|
85
|
+
function report(error) {
|
|
86
|
+
if (failures.includes(error)) return;
|
|
87
|
+
failures.push(error);
|
|
88
|
+
try { onError(error); } catch (reportError) { failures.push(reportError); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function failOutput(error) {
|
|
92
|
+
outputFailed = true;
|
|
93
|
+
report(error);
|
|
94
|
+
close();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function queueFrame(frame) {
|
|
98
|
+
if (outputFailed) return;
|
|
99
|
+
let encoded;
|
|
100
|
+
try { encoded = encodeCoreFrame(frame); } catch (error) { failOutput(error); return; }
|
|
101
|
+
writes = writes.then(
|
|
102
|
+
function writeNextFrame() {
|
|
103
|
+
if (outputFailed) return;
|
|
104
|
+
return new Promise(
|
|
105
|
+
function writeFrame(resolve, reject) {
|
|
106
|
+
output.write(
|
|
107
|
+
encoded,
|
|
108
|
+
function frameWritten(error) {
|
|
109
|
+
if (error) reject(error);
|
|
110
|
+
else resolve();
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
).catch(failOutput);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const unsubscribe = runtime.onFrame(queueFrame);
|
|
120
|
+
|
|
121
|
+
function receiveFrame(frame) {
|
|
122
|
+
const task = runtime.handle(frame);
|
|
123
|
+
pending.add(task);
|
|
124
|
+
task.then(
|
|
125
|
+
function requestSettled() {
|
|
126
|
+
pending.delete(task);
|
|
127
|
+
},
|
|
128
|
+
function requestFailed(error) {
|
|
129
|
+
pending.delete(task);
|
|
130
|
+
report(error);
|
|
131
|
+
try { runtime.emit('core.error', serializeCoreError(error)); } catch (eventError) { report(eventError); }
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const decoder = createCoreFrameDecoder(receiveFrame);
|
|
137
|
+
|
|
138
|
+
function receiveInput(chunk) {
|
|
139
|
+
try { decoder.push(chunk); } catch (error) { report(error); close(); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function inputEnded() {
|
|
143
|
+
try { decoder.finish(); } catch (error) { report(error); }
|
|
144
|
+
close();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function inputFailed(error) {
|
|
148
|
+
report(error);
|
|
149
|
+
close();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function detachInput() {
|
|
153
|
+
input.off('data', receiveInput);
|
|
154
|
+
input.off('end', inputEnded);
|
|
155
|
+
input.off('close', inputEnded);
|
|
156
|
+
input.pause();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function releaseTransportListeners() {
|
|
160
|
+
unsubscribe();
|
|
161
|
+
output.off('error', failOutput);
|
|
162
|
+
// destroy(error) marks a stream destroyed before its scheduled error
|
|
163
|
+
// and close events. The native completion owner observes their delivery;
|
|
164
|
+
// stream flags alone do not establish that pending errors were observed.
|
|
165
|
+
if (input.destroyed && !inputCompleted) await inputCompletion;
|
|
166
|
+
cleanupInputCompletion();
|
|
167
|
+
input.off('error', inputFailed);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function close() {
|
|
171
|
+
if (closing) return closed;
|
|
172
|
+
detachInput();
|
|
173
|
+
closing = Promise.resolve().then(
|
|
174
|
+
async function drainTransport() {
|
|
175
|
+
try { await runtime.close(); } catch (error) { report(error); }
|
|
176
|
+
await Promise.allSettled([...pending]);
|
|
177
|
+
await writes;
|
|
178
|
+
await releaseTransportListeners();
|
|
179
|
+
if (failures.length) rejectClosed(new AggregateError(failures, 'Core transport closed with errors.'));
|
|
180
|
+
else resolveClosed(runtime.current());
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
closing.catch(
|
|
184
|
+
async function shutdownFailed(error) {
|
|
185
|
+
report(error);
|
|
186
|
+
await releaseTransportListeners();
|
|
187
|
+
rejectClosed(new AggregateError(failures, 'Core transport shutdown failed.'));
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
return closed;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const cleanupInputCompletion = finished(input, {readable: true, writable: false}, function inputLifecycleComplete() {
|
|
194
|
+
inputCompleted = true;
|
|
195
|
+
resolveInputCompletion();
|
|
196
|
+
});
|
|
197
|
+
input.on('data', receiveInput);
|
|
198
|
+
input.on('end', inputEnded);
|
|
199
|
+
input.on('close', inputEnded);
|
|
200
|
+
input.on('error', inputFailed);
|
|
201
|
+
output.on('error', failOutput);
|
|
202
|
+
try { runtime.start(); } catch (error) { report(error); close(); }
|
|
203
|
+
if (!closing) {
|
|
204
|
+
if (input.errored) report(input.errored);
|
|
205
|
+
if (input.readableEnded || input.destroyed) inputEnded();
|
|
206
|
+
else input.resume();
|
|
207
|
+
}
|
|
208
|
+
return {runtime, closed, close};
|
|
209
|
+
}
|